From 44a6892f037ba0128ad8ec026b393e4678b7709b Mon Sep 17 00:00:00 2001 From: ceifa Date: Sat, 18 Jan 2025 13:36:21 -0300 Subject: [PATCH 01/52] change apis to accept an option object directly --- src/factory.ts | 17 ++++++++++------- src/luawasm.ts | 20 +++++++++++++++----- src/types.ts | 2 -- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/factory.ts b/src/factory.ts index 63449f5..0ef86d7 100755 --- a/src/factory.ts +++ b/src/factory.ts @@ -2,7 +2,7 @@ import version from 'package-version' import LuaEngine from './engine' import LuaWasm from './luawasm' -import { CreateEngineOptions, EnvironmentVariables } from './types' +import { CreateEngineOptions } from './types' /** * Represents a factory for creating and configuring Lua engines. @@ -12,21 +12,24 @@ export default class LuaFactory { /** * Constructs a new LuaFactory instance. - * @param [customWasmUri] - Custom URI for the Lua WebAssembly module. - * @param [environmentVariables] - Environment variables for the Lua engine. + * @param opts.wasmFile - Custom URI for the Lua WebAssembly module. + * @param opts.env - Environment variables for the Lua engine. + * @param opts.stdin - Standard input for the Lua engine. + * @param opts.stdout - Standard output for the Lua engine. + * @param opts.stderr - Standard error for the Lua engine. */ - public constructor(customWasmUri?: string, environmentVariables?: EnvironmentVariables) { - if (customWasmUri === undefined) { + public constructor(opts: Parameters[0] = {}) { + if (opts.wasmFile === undefined) { const isBrowser = (typeof window === 'object' && typeof window.document !== 'undefined') || (typeof self === 'object' && self?.constructor?.name === 'DedicatedWorkerGlobalScope') if (isBrowser) { - customWasmUri = `https://unpkg.com/wasmoon@${version}/dist/glue.wasm` + opts.wasmFile = `https://unpkg.com/wasmoon@${version}/dist/glue.wasm` } } - this.luaWasmPromise = LuaWasm.initialize(customWasmUri, environmentVariables) + this.luaWasmPromise = LuaWasm.initialize(opts) } /** diff --git a/src/luawasm.ts b/src/luawasm.ts index 98ed2cd..04b5b34 100755 --- a/src/luawasm.ts +++ b/src/luawasm.ts @@ -1,5 +1,7 @@ import initWasmModule from '../build/glue.js' -import { EnvironmentVariables, LUA_REGISTRYINDEX, LuaReturn, LuaState, LuaType } from './types' +import { LUA_REGISTRYINDEX, LuaReturn, LuaState, LuaType } from './types' + +type EnvironmentVariables = Record interface LuaEmscriptenModule extends EmscriptenModule { ccall: typeof ccall @@ -21,15 +23,23 @@ interface ReferenceMetadata { } export default class LuaWasm { - public static async initialize(customWasmFileLocation?: string, environmentVariables?: EnvironmentVariables): Promise { + public static async initialize(opts: { + wasmFile?: string + env?: EnvironmentVariables + stdin?: () => number | null + stdout?: () => number | null + stderr?: () => number | null + }): Promise { const module: LuaEmscriptenModule = await initWasmModule({ locateFile: (path: string, scriptDirectory: string) => { - return customWasmFileLocation || scriptDirectory + path + return opts.wasmFile || scriptDirectory + path }, preRun: (initializedModule: LuaEmscriptenModule) => { - if (typeof environmentVariables === 'object') { - Object.entries(environmentVariables).forEach(([k, v]) => (initializedModule.ENV[k] = v)) + if (typeof opts?.env === 'object') { + Object.entries(opts.env).forEach(([k, v]) => (initializedModule.ENV[k] = v)) } + + initializedModule.FS.init(opts?.stdin ?? null, opts?.stdout ?? null, opts?.stderr ?? null) }, }) return new LuaWasm(module) diff --git a/src/types.ts b/src/types.ts index 64e89a8..1e12c75 100755 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,5 @@ export type LuaState = number -export type EnvironmentVariables = Record - export interface CreateEngineOptions { /** Injects all the lua standard libraries (math, coroutine, debug) */ openStandardLibs?: boolean From fce233a7e719ea24ae567474adfa0dd28628c548 Mon Sep 17 00:00:00 2001 From: ceifa Date: Sat, 18 Jan 2025 13:36:28 -0300 Subject: [PATCH 02/52] convert bin to esm --- bin/wasmoon | 171 +++++++++++++++++++++----------------------- test/engine.test.js | 10 +-- tsconfig.json | 3 +- 3 files changed, 88 insertions(+), 96 deletions(-) diff --git a/bin/wasmoon b/bin/wasmoon index 9100384..92cd027 100755 --- a/bin/wasmoon +++ b/bin/wasmoon @@ -1,114 +1,107 @@ #!/usr/bin/env node -const { LuaFactory, LuaReturn, LuaType, LUA_MULTRET, decorate } = require('../dist') -const fs = require('fs') -const path = require('path') -const readline = require('readline') - -async function* walk(dir) { - const dirents = await fs.promises.readdir(dir, { withFileTypes: true }) - for (const dirent of dirents) { - const res = path.resolve(dir, dirent.name) - if (dirent.isDirectory()) { - yield* walk(res) - } else { - yield res - } +import { LuaFactory, LuaReturn, LuaType, LUA_MULTRET, decorate } from '../dist/index.js' +import pkg from '../package.json' with { type: 'json' } +import fs from 'node:fs' +import path from 'node:path' +import readline from 'node:readline' + +const factory = new LuaFactory() +const luamodule = await factory.getLuaModule() +const lua = await factory.createEngine() + +let snippets = process.argv.splice(2) + +const consumeOption = (option, single) => { + let i = -1 + const values = [] + while ((i = snippets.indexOf(option)) >= 0) { + values.push(snippets.splice(i, single ? 1 : 2).reverse()[0]) } + return values } -async function main() { - const factory = new LuaFactory() - const luamodule = await factory.getLuaModule() - const lua = await factory.createEngine() - - let snippets = process.argv.splice(2) +const warnings = consumeOption('-W', true).length > 0 +if (warnings) { + luamodule.lua_warning(lua.global.address, '@on', 0) +} - const consumeOption = (option, single) => { - let i = -1 - const values = [] - while ((i = snippets.indexOf(option)) >= 0) { - values.push(snippets.splice(i, single ? 1 : 2).reverse()[0]) - } - return values - } +const executes = consumeOption('-e') +for (const execute of executes) { + await lua.doString(execute) +} - const includes = consumeOption('-l') - const forceInteractive = consumeOption('-i', true).length > 0 - const runFile = process.stdin.isTTY && consumeOption(snippets[0], true)[0] - const args = snippets +const includes = consumeOption('-l') +const forceInteractive = consumeOption('-i', true).length > 0 +const runFile = process.stdin.isTTY && consumeOption(snippets[0], true)[0] +const args = snippets - for (const include of includes) { - const relativeInclude = path.resolve(process.cwd(), include) - const stat = await fs.promises.lstat(relativeInclude) - if (stat.isFile()) { - await factory.mountFile(relativeInclude, await fs.promises.readFile(relativeInclude)) - } else { - for await (const file of walk(relativeInclude)) { - await factory.mountFile(file, await fs.promises.readFile(file)) - } +for (const include of includes) { + const relativeInclude = path.resolve(process.cwd(), include) + const stat = await fs.promises.lstat(relativeInclude) + if (stat.isFile()) { + await factory.mountFile(relativeInclude, await fs.promises.readFile(relativeInclude)) + } else { + for (const file of await fs.promises.readdir(relativeInclude, { recursive: true })) { + await factory.mountFile(file, await fs.promises.readFile(file)) } } +} - lua.global.set('arg', decorate(args, { disableProxy: true })) +lua.global.set('arg', decorate(args, { disableProxy: true })) - const interactive = process.stdin.isTTY && (forceInteractive || !runFile) +const interactive = process.stdin.isTTY && (forceInteractive || !runFile) - if (runFile) { - const relativeRunFile = path.resolve(process.cwd(), runFile) - await factory.mountFile(relativeRunFile, await fs.promises.readFile(relativeRunFile)) +if (runFile) { + const relativeRunFile = path.resolve(process.cwd(), runFile) + await factory.mountFile(relativeRunFile, await fs.promises.readFile(relativeRunFile)) - await lua.doFile(relativeRunFile) - console.log(lua.global.indexToString(-1)) - } + await lua.doFile(relativeRunFile) + console.log(lua.global.indexToString(-1)) +} - if (!interactive && runFile) { - return - } +if (!interactive && runFile) { + process.exit(0) +} - if (interactive) { - // Call directly from module to bypass the result verification - const loadcode = (code) => !lua.global.setTop(0) && luamodule.luaL_loadstring(lua.global.address, code) === LuaReturn.Ok +if (interactive) { + // Call directly from module to bypass the result verification + const loadcode = (code) => !lua.global.setTop(0) && luamodule.luaL_loadstring(lua.global.address, code) === LuaReturn.Ok - const version = require('../package.json').version - console.log('Welcome to Wasmoon v' + version) + const version = pkg.version + const luaversion = lua.global.get('_VERSION') + console.log(`Welcome to Wasmoon ${version} (${luaversion})`) - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: true, - removeHistoryDuplicates: true, - }) + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + removeHistoryDuplicates: true, + }) - rl.prompt() + rl.prompt() - for await (const line of rl) { - const loaded = loadcode(line) || loadcode(`return ${line}`) - if (!loaded) { - console.log(lua.global.getValue(-1, LuaType.String)) - rl.prompt() - continue - } + for await (const line of rl) { + const loaded = loadcode(line) || loadcode(`return ${line}`) + if (!loaded) { + console.log(lua.global.getValue(-1, LuaType.String)) + rl.prompt() + continue + } - const result = luamodule.lua_pcallk(lua.global.address, 0, LUA_MULTRET, 0, 0, null) - if (result === LuaReturn.Ok) { - const returnValues = Array.from({ length: lua.global.getTop() }).map((_, i) => lua.global.indexToString(i + 1)) + const result = luamodule.lua_pcallk(lua.global.address, 0, LUA_MULTRET, 0, 0, null) + if (result === LuaReturn.Ok) { + const returnValues = Array.from({ length: lua.global.getTop() }).map((_, i) => lua.global.indexToString(i + 1)) - if (returnValues.length) { - console.log(...returnValues) - } - } else { - console.log(lua.global.getValue(-1, LuaType.String)) + if (returnValues.length) { + console.log(...returnValues) } - - rl.prompt() + } else { + console.log(lua.global.getValue(-1, LuaType.String)) } - } else { - await lua.doString(fs.readFileSync(0, 'utf-8')) - console.log(lua.global.indexToString(-1)) + + rl.prompt() } +} else { + await lua.doString(fs.readFileSync(0, 'utf-8')) + console.log(lua.global.indexToString(-1)) } - -main().catch((err) => { - console.error(err) - process.exit(1) -}) diff --git a/test/engine.test.js b/test/engine.test.js index 23e6361..3a1ab1f 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -806,13 +806,13 @@ describe('Engine', () => { it('lots of doString calls should succeed', async () => { const engine = await getEngine() - const length = 10000; + const length = 10000 for (let i = 0; i < length; i++) { - const a = Math.floor(Math.random() * 100); - const b = Math.floor(Math.random() * 100); - const result = await engine.doString(`return ${a} + ${b};`); - expect(result).to.equal(a + b); + const a = Math.floor(Math.random() * 100) + const b = Math.floor(Math.random() * 100) + const result = await engine.doString(`return ${a} + ${b};`) + expect(result).to.equal(a + b) } }) }) diff --git a/tsconfig.json b/tsconfig.json index f911e66..14dbd93 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,8 +16,7 @@ "noUnusedLocals": true, "importHelpers": true, "strict": true, - "resolveJsonModule": true, - + "resolveJsonModule": true }, "include": ["src/**/*", "test/**/*", "bench/**/*", "eslint.config.js"], "exclude": ["node_modules"] From 867ae7068c9516b9c1354a374f82fe7b80780854 Mon Sep 17 00:00:00 2001 From: ceifa Date: Sat, 18 Jan 2025 17:03:52 -0300 Subject: [PATCH 03/52] wip: new improved cli --- bin/wasmoon | 263 ++++++++++++++++++++++++++++++++++++++--------- build.sh | 4 +- eslint.config.js | 2 +- src/global.ts | 2 +- src/luawasm.ts | 3 +- 5 files changed, 224 insertions(+), 50 deletions(-) diff --git a/bin/wasmoon b/bin/wasmoon index 92cd027..d262f60 100755 --- a/bin/wasmoon +++ b/bin/wasmoon @@ -1,98 +1,266 @@ #!/usr/bin/env node -import { LuaFactory, LuaReturn, LuaType, LUA_MULTRET, decorate } from '../dist/index.js' +import { LuaFactory, LuaReturn, LuaType, LUA_MULTRET, LuaRawResult, decorate, decorateFunction } from '../dist/index.js' import pkg from '../package.json' with { type: 'json' } import fs from 'node:fs' import path from 'node:path' import readline from 'node:readline' -const factory = new LuaFactory() -const luamodule = await factory.getLuaModule() -const lua = await factory.createEngine() +function printUsage() { + console.log( + ` +usage: wasmoon [options] [script [args]] +Available options are: + -e stat execute string 'stat' + -i enter interactive mode after executing 'script' + -l mod require library 'mod' into global 'mod' + -l g=mod require library 'mod' into global 'g' + -v show version information + -E ignore environment variables + -W turn warnings on + -- stop handling options + - stop handling options and execute stdin +`.trim(), + ) + process.exit(1) +} + +function parseArgs(args) { + const executeSnippets = [] + const includeModules = [] + let forceInteractive = false + let warnings = false + let ignoreEnv = false + let showVersion = false + let scriptFile = null + + const outArgs = [] + + let i = 0 + for (; i < args.length; i++) { + const arg = args[i] + + if (arg === '--') { + i++ + break + } + + if (arg.startsWith('-') && arg.length > 1) { + switch (arg) { + case '-v': + showVersion = true + break + + case '-W': + warnings = true + break + + case '-E': + ignoreEnv = true + break + + case '-i': + forceInteractive = true + break + + case '-e': + i++ + if (i >= args.length) { + console.error('Missing argument after -e') + printUsage() + } + executeSnippets.push(args[i]) + break + + case '-l': + i++ + if (i >= args.length) { + console.error('Missing argument after -l') + printUsage() + } + includeModules.push(args[i]) + break -let snippets = process.argv.splice(2) + case '-': + scriptFile = '-' + i++ + break -const consumeOption = (option, single) => { - let i = -1 - const values = [] - while ((i = snippets.indexOf(option)) >= 0) { - values.push(snippets.splice(i, single ? 1 : 2).reverse()[0]) + default: + console.log(`unrecognized option: '${arg}'`) + printUsage() + break + } + } else { + scriptFile = arg + i++ + break + } } - return values + + outArgs.push(...args.slice(i)) + + return { + executeSnippets, + includeModules, + forceInteractive, + warnings, + showVersion, + scriptFile, + scriptArgs: outArgs, + ignoreEnv, + } +} + +const { executeSnippets, includeModules, ignoreEnv, forceInteractive, warnings, showVersion, scriptFile, scriptArgs } = parseArgs( + process.argv.slice(2), +) + +const factory = new LuaFactory({ env: ignoreEnv ? undefined : process.env }) +const luamodule = await factory.getLuaModule() +const lua = await factory.createEngine() + +if (showVersion) { + console.log(`wasmoon ${pkg.version} (${lua.global.get('_VERSION')})`) + process.exit(0) } -const warnings = consumeOption('-W', true).length > 0 if (warnings) { luamodule.lua_warning(lua.global.address, '@on', 0) } -const executes = consumeOption('-e') -for (const execute of executes) { - await lua.doString(execute) +for (const snippet of executeSnippets) { + await lua.doString(snippet) } -const includes = consumeOption('-l') -const forceInteractive = consumeOption('-i', true).length > 0 -const runFile = process.stdin.isTTY && consumeOption(snippets[0], true)[0] -const args = snippets +function currentDir() { + const pointer = luamodule.module._malloc(128); + const baseidx = pointer >> 2; -for (const include of includes) { - const relativeInclude = path.resolve(process.cwd(), include) - const stat = await fs.promises.lstat(relativeInclude) - if (stat.isFile()) { - await factory.mountFile(relativeInclude, await fs.promises.readFile(relativeInclude)) - } else { - for (const file of await fs.promises.readdir(relativeInclude, { recursive: true })) { - await factory.mountFile(file, await fs.promises.readFile(file)) + try { + if (!luamodule.lua_getstack(lua.global.address, 1, pointer)) { + return null; + } + + if (!luamodule.lua_getinfo(lua.global.address, 'S', pointer)) { + return null; } + + // https://www.lua.org/manual/5.4/manual.html#lua_Debug + const sourcePtr = luamodule.module.HEAPU32[baseidx + 4]; + let source = luamodule.module.UTF8ToString(sourcePtr); + + if (!source || source === '=[C]' || source.startsWith('return ')) { + return '.' + } else { + if (source.startsWith('@')) { + source = source.substring(1) + } + + return source + } + } finally { + luamodule.module._free(pointer); } } -lua.global.set('arg', decorate(args, { disableProxy: true })) +lua.global.getTable('package', (packageIdx) => { + luamodule.lua_getfield(lua.global.address, packageIdx, 'searchers') -const interactive = process.stdin.isTTY && (forceInteractive || !runFile) + lua.global.pushValue(decorateFunction((thread, moduleName) => { + if (moduleName.endsWith('.lua')) { + const calledDirectory = currentDir() + const luafile = path.resolve(calledDirectory, moduleName) -if (runFile) { - const relativeRunFile = path.resolve(process.cwd(), runFile) - await factory.mountFile(relativeRunFile, await fs.promises.readFile(relativeRunFile)) + try { + const content = fs.readFileSync(luafile) + factory.mountFileSync(luamodule, luafile, content) + } catch { + // ignore + } - await lua.doFile(relativeRunFile) - console.log(lua.global.indexToString(-1)) + const load = luamodule.luaL_loadfilex(lua.global.address, luafile, null) + if (load === LuaReturn.Ok) { + thread.pushValue(luafile) + return new LuaRawResult(2) + } + } + }, { receiveThread: true })) + + const len = luamodule.lua_rawlen(lua.global.address, -1) + luamodule.lua_rawseti(lua.global.address, -2, len + BigInt(1)) + + lua.global.pop() +}) + +lua.global.set('loadfile', (filename) => { + // TODO +}) + +for (const include of includeModules) { + // TODO } -if (!interactive && runFile) { - process.exit(0) +lua.global.set('arg', decorate(scriptArgs, { disableProxy: true })) + +const isTTY = process.stdin.isTTY + +if (scriptFile) { + if (scriptFile === '-') { + const input = fs.readFileSync(0, 'utf-8') + await lua.doString(input) + console.log(lua.global.indexToString(-1)) + } else { + const absolutePath = path.resolve(process.cwd(), scriptFile) + const content = await fs.promises.readFile(absolutePath) + await factory.mountFile(absolutePath, content) + await lua.doFile(absolutePath) + console.log(lua.global.indexToString(-1)) + } } -if (interactive) { - // Call directly from module to bypass the result verification - const loadcode = (code) => !lua.global.setTop(0) && luamodule.luaL_loadstring(lua.global.address, code) === LuaReturn.Ok +const shouldInteractive = isTTY && (forceInteractive || (!scriptFile && !executeSnippets.length)) + +if (shouldInteractive) { + // Bypass result verification for interactive mode + const loadcode = (code) => { + lua.global.setTop(0) + return luamodule.luaL_loadstring(lua.global.address, code) === LuaReturn.Ok + } const version = pkg.version const luaversion = lua.global.get('_VERSION') console.log(`Welcome to Wasmoon ${version} (${luaversion})`) + console.log('Type Lua code and press Enter to execute. Ctrl+C to exit.\n') const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true, removeHistoryDuplicates: true, + prompt: '> ', }) rl.prompt() for await (const line of rl) { - const loaded = loadcode(line) || loadcode(`return ${line}`) + // try to load (compile) it first as an expression (return ) and second as a statement + const loaded = loadcode(`return ${line}`) || loadcode(line) if (!loaded) { - console.log(lua.global.getValue(-1, LuaType.String)) + // Failed to parse + const err = lua.global.getValue(-1, LuaType.String) + console.log(err) rl.prompt() continue } const result = luamodule.lua_pcallk(lua.global.address, 0, LUA_MULTRET, 0, 0, null) if (result === LuaReturn.Ok) { - const returnValues = Array.from({ length: lua.global.getTop() }).map((_, i) => lua.global.indexToString(i + 1)) - - if (returnValues.length) { + const count = lua.global.getTop() + if (count > 0) { + const returnValues = [] + for (let i = 1; i <= count; i++) { + returnValues.push(lua.global.indexToString(i)) + } console.log(...returnValues) } } else { @@ -101,7 +269,10 @@ if (interactive) { rl.prompt() } -} else { - await lua.doString(fs.readFileSync(0, 'utf-8')) +} else if (!scriptFile) { + // If we're not interactive, and we did NOT run a file, + // read from stdin until EOF. (Non-TTY or no -i, no file). + const input = fs.readFileSync(0, 'utf-8') + await lua.doString(input) console.log(lua.global.indexToString(-1)) } diff --git a/build.sh b/build.sh index 9d54f96..774b0c3 100755 --- a/build.sh +++ b/build.sh @@ -24,7 +24,9 @@ emcc \ 'setValue', \ 'lengthBytesUTF8', \ 'stringToUTF8', \ - 'stringToNewUTF8' + 'stringToNewUTF8', \ + 'UTF8ToString', \ + 'HEAPU32' ]" \ -s INCOMING_MODULE_JS_API="[ 'locateFile', \ diff --git a/eslint.config.js b/eslint.config.js index 1610949..23e7807 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,7 +22,7 @@ export default [ }, }, { - files: ['**/*.js', '**/*.mjs', '**/*.ts'], + files: ['**/*.js', '**/*.mjs', '**/*.ts', './bin/*'], ignores: ['**/test/*', '**/bench/*'], plugins: { 'simple-import-sort': simpleImportSort, diff --git a/src/global.ts b/src/global.ts index e006d5e..0fbef7a 100755 --- a/src/global.ts +++ b/src/global.ts @@ -177,7 +177,7 @@ export default class Global extends Thread { } finally { // +1 for the table if (this.getTop() !== startStackTop + 1) { - console.warn(`getTable: expected stack size ${startStackTop} got ${this.getTop()}`) + console.warn(`getTable: expected stack size ${startStackTop + 1} got ${this.getTop()}`) } this.setTop(startStackTop) } diff --git a/src/luawasm.ts b/src/luawasm.ts index 04b5b34..032ba9e 100755 --- a/src/luawasm.ts +++ b/src/luawasm.ts @@ -13,6 +13,7 @@ interface LuaEmscriptenModule extends EmscriptenModule { stringToNewUTF8: typeof allocateUTF8 lengthBytesUTF8: typeof lengthBytesUTF8 stringToUTF8: typeof stringToUTF8 + UTF8ToString: typeof UTF8ToString ENV: EnvironmentVariables _realloc: (pointer: number, size: number) => number } @@ -121,7 +122,7 @@ export default class LuaWasm { public lua_tointegerx: (L: LuaState, idx: number, isnum: number | null) => bigint public lua_toboolean: (L: LuaState, idx: number) => number public lua_tolstring: (L: LuaState, idx: number, len: number | null) => string - public lua_rawlen: (L: LuaState, idx: number) => number + public lua_rawlen: (L: LuaState, idx: number) => bigint public lua_tocfunction: (L: LuaState, idx: number) => number public lua_touserdata: (L: LuaState, idx: number) => number public lua_tothread: (L: LuaState, idx: number) => LuaState From eae545ba4b91ea07f1c2663bec3d9a2793c95ac0 Mon Sep 17 00:00:00 2001 From: ceifa Date: Tue, 18 Feb 2025 09:15:22 -0300 Subject: [PATCH 04/52] implement option to use raw node fs --- bin/wasmoon | 78 +------ build.sh | 1 + package-lock.json | 401 +++++++++++++++++++----------------- package.json | 26 +-- rolldown.config.ts | 2 +- src/factory.ts | 1 + src/luawasm.ts | 20 +- test/initialization.test.js | 2 +- test/luatests.js | 17 +- test/utils.js | 2 +- tsconfig.json | 5 +- 11 files changed, 260 insertions(+), 295 deletions(-) diff --git a/bin/wasmoon b/bin/wasmoon index d262f60..2abc218 100755 --- a/bin/wasmoon +++ b/bin/wasmoon @@ -115,7 +115,7 @@ const { executeSnippets, includeModules, ignoreEnv, forceInteractive, warnings, process.argv.slice(2), ) -const factory = new LuaFactory({ env: ignoreEnv ? undefined : process.env }) +const factory = new LuaFactory({ env: ignoreEnv ? undefined : process.env, fs: 'node' }) const luamodule = await factory.getLuaModule() const lua = await factory.createEngine() @@ -128,76 +128,18 @@ if (warnings) { luamodule.lua_warning(lua.global.address, '@on', 0) } -for (const snippet of executeSnippets) { - await lua.doString(snippet) -} - -function currentDir() { - const pointer = luamodule.module._malloc(128); - const baseidx = pointer >> 2; - - try { - if (!luamodule.lua_getstack(lua.global.address, 1, pointer)) { - return null; - } - - if (!luamodule.lua_getinfo(lua.global.address, 'S', pointer)) { - return null; - } - - // https://www.lua.org/manual/5.4/manual.html#lua_Debug - const sourcePtr = luamodule.module.HEAPU32[baseidx + 4]; - let source = luamodule.module.UTF8ToString(sourcePtr); - - if (!source || source === '=[C]' || source.startsWith('return ')) { - return '.' - } else { - if (source.startsWith('@')) { - source = source.substring(1) - } - - return source - } - } finally { - luamodule.module._free(pointer); +for (const module of includeModules) { + let [global, mod] = module.split('=') + if (!mod) { + mod = global } -} - -lua.global.getTable('package', (packageIdx) => { - luamodule.lua_getfield(lua.global.address, packageIdx, 'searchers') - lua.global.pushValue(decorateFunction((thread, moduleName) => { - if (moduleName.endsWith('.lua')) { - const calledDirectory = currentDir() - const luafile = path.resolve(calledDirectory, moduleName) - - try { - const content = fs.readFileSync(luafile) - factory.mountFileSync(luamodule, luafile, content) - } catch { - // ignore - } - - const load = luamodule.luaL_loadfilex(lua.global.address, luafile, null) - if (load === LuaReturn.Ok) { - thread.pushValue(luafile) - return new LuaRawResult(2) - } - } - }, { receiveThread: true })) - - const len = luamodule.lua_rawlen(lua.global.address, -1) - luamodule.lua_rawseti(lua.global.address, -2, len + BigInt(1)) - - lua.global.pop() -}) - -lua.global.set('loadfile', (filename) => { - // TODO -}) + const require = lua.global.get('require') + lua.global.set(global, require(mod)) +} -for (const include of includeModules) { - // TODO +for (const snippet of executeSnippets) { + await lua.doString(snippet) } lua.global.set('arg', decorate(scriptArgs, { disableProxy: true })) diff --git a/build.sh b/build.sh index 774b0c3..252517e 100755 --- a/build.sh +++ b/build.sh @@ -13,6 +13,7 @@ else fi emcc \ + -lnodefs.js \ -s WASM=1 $extension -o ./build/glue.js \ -s EXPORTED_RUNTIME_METHODS="[ 'ccall', \ diff --git a/package-lock.json b/package-lock.json index a9b3163..4e37b2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,29 +9,29 @@ "version": "1.16.0", "license": "MIT", "dependencies": { - "@types/emscripten": "1.39.10" + "@types/emscripten": "1.40.0" }, "bin": { "wasmoon": "bin/wasmoon" }, "devDependencies": { - "@eslint/js": "9.17.0", - "@types/node": "22.10.2", - "@typescript-eslint/parser": "8.18.2", - "chai": "5.1.2", + "@eslint/js": "9.20.0", + "@types/node": "22.13.4", + "@typescript-eslint/parser": "8.24.1", + "chai": "5.2.0", "chai-as-promised": "8.0.1", - "eslint": "9.17.0", - "eslint-config-prettier": "9.1.0", - "eslint-plugin-prettier": "5.2.1", + "eslint": "9.20.1", + "eslint-config-prettier": "10.0.1", + "eslint-plugin-prettier": "5.2.3", "eslint-plugin-simple-import-sort": "12.1.1", "fengari": "0.1.4", - "mocha": "11.0.1", - "prettier": "3.4.2", - "rolldown": "1.0.0-beta.1-commit.7c52c94", + "mocha": "11.1.0", + "prettier": "3.5.1", + "rolldown": "1.0.0-beta.3", "rollup-plugin-copy": "3.5.0", "tslib": "2.8.1", - "typescript": "5.7.2", - "typescript-eslint": "8.18.2" + "typescript": "5.7.3", + "typescript-eslint": "8.24.1" } }, "node_modules/@emnapi/core": { @@ -137,9 +137,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.1.tgz", - "integrity": "sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", + "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -198,9 +198,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.17.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz", - "integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==", + "version": "9.20.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.20.0.tgz", + "integrity": "sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==", "dev": true, "license": "MIT", "engines": { @@ -218,12 +218,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.4.tgz", - "integrity": "sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.5.tgz", + "integrity": "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==", "dev": true, "license": "Apache-2.0", "dependencies": { + "@eslint/core": "^0.10.0", "levn": "^0.4.1" }, "engines": { @@ -366,9 +367,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.45.0.tgz", - "integrity": "sha512-s1xCyuYV024s4Jh9l3a9/gSyIG5qr6P0gdwz03UMx6UqaXRkhD2INeRSNxGM/XXKfYVbAqUBy3q/QEMkTNio9Q==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.46.0.tgz", + "integrity": "sha512-BHU261xrLasw04d2cToR36F6VV0T7t62rtQUprvBRL4Uru9P23moMkDmZUMSZSQj0fIUTA3oTOTwQ7cc4Av/iw==", "dev": true, "license": "MIT", "funding": { @@ -400,9 +401,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-beta.1-commit.7c52c94", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.1-commit.7c52c94.tgz", - "integrity": "sha512-02iMa/cL+kLkS6xC98MBZSkpInFtAK0gKxjQhmdvplF+WMr/i4VUDrwEIP+N0ydOiUw3rfXcz+Vykh2Srw2ioQ==", + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.3.tgz", + "integrity": "sha512-qB1ofY+09nDYYaEi5kVsjqy4cKsVPI9E5bkV46CRrQsTF/BBM29wpvaj8qTRQ41qwInFA5kmqnVVr35yfH7ddw==", "cpu": [ "arm64" ], @@ -414,9 +415,9 @@ ] }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-beta.1-commit.7c52c94", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.1-commit.7c52c94.tgz", - "integrity": "sha512-14jCHf59q/SeqMvhq5cah3qwC6/G7Z/64Z77jgwofFLyGbdYmFFv4ct92vXG3Wno88MwAbuilKIaOiu0HhpBmQ==", + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.3.tgz", + "integrity": "sha512-Fk+rqyeszMaZK12wItqFDXdUadg+TVQqOPh0fdaCefVebd29N+9fpFrARyo8gReyt/lcnEN4nWgdn7l99R70QA==", "cpu": [ "x64" ], @@ -428,9 +429,9 @@ ] }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-beta.1-commit.7c52c94", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.1-commit.7c52c94.tgz", - "integrity": "sha512-iICCEIauX0xGByel25JkMG8jTN3zF70XHaon6ylbkCsqpZzXTn9qx/KTUKEv0aeoxeAU6JU7Sxk9aK6KHPHkHQ==", + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.3.tgz", + "integrity": "sha512-B7QzJKu53MB/hvwO276AsyxN+p9lfgCkIO94TQB6t3auq3pDCC6u6gdRI1Ydwn6/gpMLiUNCW4mnpxCE5fE5tg==", "cpu": [ "x64" ], @@ -442,9 +443,9 @@ ] }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-beta.1-commit.7c52c94", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.1-commit.7c52c94.tgz", - "integrity": "sha512-xS/2DVlMhi9BcJ15uAvIFdMuGS4bKpOsHkwoVIYxkGNKcLD8J4kDK0Uaa9Wkb191pygqmZZYe+y+m5dp8WyEQQ==", + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.3.tgz", + "integrity": "sha512-NB5JrXP5dAigDTbvVc6VWiOY3Rr/0u1pi/9LYoBtMYiST7hYOrBPO9lvDF9w/23yKCr1+8PF4wFGR/YxKTNN5Q==", "cpu": [ "arm" ], @@ -456,9 +457,9 @@ ] }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-beta.1-commit.7c52c94", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.1-commit.7c52c94.tgz", - "integrity": "sha512-4Wtczg6ZjIqYZ+r5s6KxqUJ1XTcMHk9b0PunedmRsx1Po9lXLhLtD7xMSLrPHT2s6upj/ast5Nc1CocBlGH2kQ==", + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.3.tgz", + "integrity": "sha512-bYyZLXzJ2boZ7CdUuCSAaTcWkVKcBUOL+B86zv+tRyrtk4BIpHF+L+vOg5uPD/PHwrIglxAno5MN4NnpkUj5fQ==", "cpu": [ "arm64" ], @@ -470,9 +471,9 @@ ] }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-beta.1-commit.7c52c94", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.1-commit.7c52c94.tgz", - "integrity": "sha512-WMSnRcek6BRXnOiZisTjmKD93BrugSLkJngbZZvXYoPTXLb19pPntnN7hV9J/V7UkgjdXAdwJwtzUfHfqUzWrg==", + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.3.tgz", + "integrity": "sha512-t/jaaFrCSvwX2075jRfa2bwAcsuTtY1/sIT4XqsDg2MVxWQtaUyBx5Mi0pqZKTjdOPnL+f/zoUC9dxT2lUpNmw==", "cpu": [ "arm64" ], @@ -484,9 +485,9 @@ ] }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-beta.1-commit.7c52c94", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.1-commit.7c52c94.tgz", - "integrity": "sha512-M5kXmTgi8aP9GKzMcLtbpQ5xPic2xzuilazT0Q8oCbU3rcQg39OTs2A/1pNGqqVzertVWmMw473jDs+39MF4KQ==", + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.3.tgz", + "integrity": "sha512-EeDNLPU0Xw8ByRWxNLO30AF0fKYkdb/6rH5G073NFBDkj7ggYR/CvsNBjtDeCJ7+I6JG4xUjete2+VeV+GQjiA==", "cpu": [ "x64" ], @@ -498,9 +499,9 @@ ] }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-beta.1-commit.7c52c94", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.1-commit.7c52c94.tgz", - "integrity": "sha512-4i2hLAjupLmxTRqk6YZORs7CKCdXmzymNTy64rfoSmiL5iN4Ike9erB++pUpmmqG8UmOvrZXzbMWwvVd9GIPPw==", + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.3.tgz", + "integrity": "sha512-iTcAj8FKac3nyQhvFuqKt6Xqu9YNDbe1ew6US2OSN4g3zwfujgylaRCitEG+Uzd7AZfSVVLAfqrxKMa36Sj9Mg==", "cpu": [ "x64" ], @@ -512,9 +513,9 @@ ] }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-beta.1-commit.7c52c94", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.1-commit.7c52c94.tgz", - "integrity": "sha512-EekMm2D41TDmpqdhVXeXM0dU4SHFe2tZBY9ondJhA2lOHW0No5Y/i2D5dXauaGDBYljZldhUL/oRINc0/1uF8A==", + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.3.tgz", + "integrity": "sha512-sYgbsbyspvVZ2zplqsTxjf2N3e8UQGQnSsN5u4bMX461gY5vAsjUiA4nf1/ztDBMHWT79lF2QNx4csjnjSxMlA==", "cpu": [ "wasm32" ], @@ -529,9 +530,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-beta.1-commit.7c52c94", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.1-commit.7c52c94.tgz", - "integrity": "sha512-rubF+iwgtmeZfyvR+1y3qYsRWqi0qtDqI6vrDjbyXC7i4NU6/Lpcd5aS60eMJc7chQ9E64SNxddi6V7H38er/g==", + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.3.tgz", + "integrity": "sha512-qszMtrWybBLTFaew2WgEBRMlz1B/V8XxU87uezXlKcLW36aoRWR8LspZvqqoBkvJzbQtfOgm1HdTIk/v3Rn7QQ==", "cpu": [ "arm64" ], @@ -543,9 +544,9 @@ ] }, "node_modules/@rolldown/binding-win32-ia32-msvc": { - "version": "1.0.0-beta.1-commit.7c52c94", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.1-commit.7c52c94.tgz", - "integrity": "sha512-qo4Vd1i+tsHQ1AYMgy1vTYcDwcb9Pnzjve1ni97PUdzMc2EtR8BvamX0QxEdlYRZGNiv7PXFVUlTzIpPLimL8w==", + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.3.tgz", + "integrity": "sha512-J+mzAO68VK91coLVuUln/XN0ummIEOODyupZ2BmXY8suBHPVAyLLAP54rlucBPQmzU8fI6DXM2bl2whZ+KEXpQ==", "cpu": [ "ia32" ], @@ -557,9 +558,9 @@ ] }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-beta.1-commit.7c52c94", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.1-commit.7c52c94.tgz", - "integrity": "sha512-vdhlPFeNk1UNF4t52Lg1Y1FEvjFbYqtbpxz2w8M+HozdJLSRaVJdXPI/tMMFhdC/YlMFRNrZN5W+PwHUhbFxSQ==", + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.3.tgz", + "integrity": "sha512-r06rAi+1eStgavGnw+2y4F7gpb0w9ocnKk0Ir7LmegLAkMZ/v4Fjo9jZUrLTLtmI36108v1uvUPrIAFzFOWE7g==", "cpu": [ "x64" ], @@ -582,9 +583,9 @@ } }, "node_modules/@types/emscripten": { - "version": "1.39.10", - "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.10.tgz", - "integrity": "sha512-TB/6hBkYQJxsZHSqyeuO1Jt0AB/bW6G7rHt9g7lML7SOF6lbgcHvw/Lr+69iqN0qxgXLhWKScAon73JNnptuDw==", + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.40.0.tgz", + "integrity": "sha512-MD2JJ25S4tnjnhjWyalMS6K6p0h+zQV6+Ylm+aGbiS8tSn/aHLSGNzBgduj6FB4zH0ax2GRMGYi/8G1uOxhXWA==", "license": "MIT" }, "node_modules/@types/estree": { @@ -630,9 +631,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.10.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", - "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "version": "22.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz", + "integrity": "sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg==", "dev": true, "license": "MIT", "dependencies": { @@ -640,21 +641,21 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.2.tgz", - "integrity": "sha512-adig4SzPLjeQ0Tm+jvsozSGiCliI2ajeURDGHjZ2llnA+A67HihCQ+a3amtPhUakd1GlwHxSRvzOZktbEvhPPg==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.1.tgz", + "integrity": "sha512-ll1StnKtBigWIGqvYDVuDmXJHVH4zLVot1yQ4fJtLpL7qacwkxJc1T0bptqw+miBQ/QfUbhl1TcQ4accW5KUyA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.18.2", - "@typescript-eslint/type-utils": "8.18.2", - "@typescript-eslint/utils": "8.18.2", - "@typescript-eslint/visitor-keys": "8.18.2", + "@typescript-eslint/scope-manager": "8.24.1", + "@typescript-eslint/type-utils": "8.24.1", + "@typescript-eslint/utils": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -670,16 +671,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.18.2.tgz", - "integrity": "sha512-y7tcq4StgxQD4mDr9+Jb26dZ+HTZ/SkfqpXSiqeUXZHxOUyjWDKsmwKhJ0/tApR08DgOhrFAoAhyB80/p3ViuA==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.24.1.tgz", + "integrity": "sha512-Tqoa05bu+t5s8CTZFaGpCH2ub3QeT9YDkXbPd3uQ4SfsLoh1/vv2GEYAioPoxCWJJNsenXlC88tRjwoHNts1oQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.18.2", - "@typescript-eslint/types": "8.18.2", - "@typescript-eslint/typescript-estree": "8.18.2", - "@typescript-eslint/visitor-keys": "8.18.2", + "@typescript-eslint/scope-manager": "8.24.1", + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/typescript-estree": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1", "debug": "^4.3.4" }, "engines": { @@ -695,14 +696,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.18.2.tgz", - "integrity": "sha512-YJFSfbd0CJjy14r/EvWapYgV4R5CHzptssoag2M7y3Ra7XNta6GPAJPPP5KGB9j14viYXyrzRO5GkX7CRfo8/g==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.1.tgz", + "integrity": "sha512-OdQr6BNBzwRjNEXMQyaGyZzgg7wzjYKfX2ZBV3E04hUCBDv3GQCHiz9RpqdUIiVrMgJGkXm3tcEh4vFSHreS2Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.18.2", - "@typescript-eslint/visitor-keys": "8.18.2" + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -713,16 +714,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.18.2.tgz", - "integrity": "sha512-AB/Wr1Lz31bzHfGm/jgbFR0VB0SML/hd2P1yxzKDM48YmP7vbyJNHRExUE/wZsQj2wUCvbWH8poNHFuxLqCTnA==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.24.1.tgz", + "integrity": "sha512-/Do9fmNgCsQ+K4rCz0STI7lYB4phTtEXqqCAs3gZW0pnK7lWNkvWd5iW545GSmApm4AzmQXmSqXPO565B4WVrw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.18.2", - "@typescript-eslint/utils": "8.18.2", + "@typescript-eslint/typescript-estree": "8.24.1", + "@typescript-eslint/utils": "8.24.1", "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -737,9 +738,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.18.2.tgz", - "integrity": "sha512-Z/zblEPp8cIvmEn6+tPDIHUbRu/0z5lqZ+NvolL5SvXWT5rQy7+Nch83M0++XzO0XrWRFWECgOAyE8bsJTl1GQ==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.1.tgz", + "integrity": "sha512-9kqJ+2DkUXiuhoiYIUvIYjGcwle8pcPpdlfkemGvTObzgmYfJ5d0Qm6jwb4NBXP9W1I5tss0VIAnWFumz3mC5A==", "dev": true, "license": "MIT", "engines": { @@ -751,20 +752,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.18.2.tgz", - "integrity": "sha512-WXAVt595HjpmlfH4crSdM/1bcsqh+1weFRWIa9XMTx/XHZ9TCKMcr725tLYqWOgzKdeDrqVHxFotrvWcEsk2Tg==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.1.tgz", + "integrity": "sha512-UPyy4MJ/0RE648DSKQe9g0VDSehPINiejjA6ElqnFaFIhI6ZEiZAkUI0D5MCk0bQcTf/LVqZStvQ6K4lPn/BRg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.18.2", - "@typescript-eslint/visitor-keys": "8.18.2", + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -778,16 +779,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.18.2.tgz", - "integrity": "sha512-Cr4A0H7DtVIPkauj4sTSXVl+VBWewE9/o40KcF3TV9aqDEOWoXF3/+oRXNby3DYzZeCATvbdksYsGZzplwnK/Q==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.1.tgz", + "integrity": "sha512-OOcg3PMMQx9EXspId5iktsI3eMaXVwlhC8BvNnX6B5w9a4dVgpkQZuU8Hy67TolKcl+iFWq0XX+jbDGN4xWxjQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.18.2", - "@typescript-eslint/types": "8.18.2", - "@typescript-eslint/typescript-estree": "8.18.2" + "@typescript-eslint/scope-manager": "8.24.1", + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/typescript-estree": "8.24.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -802,13 +803,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.18.2.tgz", - "integrity": "sha512-zORcwn4C3trOWiCqFQP1x6G3xTRyZ1LYydnj51cRnJ6hxBlr/cKPckk+PKPUw/fXmvfKTcw7bwY3w9izgx5jZw==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.1.tgz", + "integrity": "sha512-EwVHlp5l+2vp8CoqJm9KikPZgi3gbdZAtabKT9KPShGeOcJhsv4Zdo3oc8T8I0uKEmYoU4ItyxbptjF08enaxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.18.2", + "@typescript-eslint/types": "8.24.1", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -833,9 +834,9 @@ } }, "node_modules/@valibot/to-json-schema": { - "version": "1.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@valibot/to-json-schema/-/to-json-schema-1.0.0-beta.3.tgz", - "integrity": "sha512-20XQh1u5sOLwS3NOB7oHCo3clQ9h4GlavXgLKMux2PYpHowb7P97cND0dg8T3+fE1WoKVACcLppvzAPpSx0F+Q==", + "version": "1.0.0-beta.4", + "resolved": "https://registry.npmjs.org/@valibot/to-json-schema/-/to-json-schema-1.0.0-beta.4.tgz", + "integrity": "sha512-wXBdCyoqec+NLCl5ihitXzZXD4JAjPK3+HfskSXzfhiNFvKje0A/v1LygqKidUgIbaJtREmq/poJGbaS/0MKuQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1049,9 +1050,9 @@ } }, "node_modules/chai": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", - "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", "dev": true, "license": "MIT", "dependencies": { @@ -1144,15 +1145,18 @@ } }, "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", + "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" } }, "node_modules/cliui/node_modules/ansi-regex": { @@ -1376,19 +1380,19 @@ } }, "node_modules/eslint": { - "version": "9.17.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz", - "integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==", + "version": "9.20.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.20.1.tgz", + "integrity": "sha512-m1mM33o6dBUjxl2qb6wv6nGNwCAsns1eKtaQ4l/NPHeTvhiUPbtdfMyktxN4B3fgHIgsYh1VT3V9txblpQHq+g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.19.0", - "@eslint/core": "^0.9.0", + "@eslint/core": "^0.11.0", "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.17.0", - "@eslint/plugin-kit": "^0.2.3", + "@eslint/js": "9.20.0", + "@eslint/plugin-kit": "^0.2.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.1", @@ -1436,22 +1440,22 @@ } }, "node_modules/eslint-config-prettier": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", - "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.1.tgz", + "integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==", "dev": true, "license": "MIT", "bin": { - "eslint-config-prettier": "bin/cli.js" + "eslint-config-prettier": "build/bin/cli.js" }, "peerDependencies": { "eslint": ">=7.0.0" } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", - "integrity": "sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", + "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", "dev": true, "license": "MIT", "dependencies": { @@ -1519,6 +1523,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/core": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.11.0.tgz", + "integrity": "sha512-DWUB2pksgNEb6Bz2fggIy1wh6fGgZP4Xyy/Mt0QZPiloKKXerbqq9D3SBQTlCRYOrcRPu4vuz+CGjwdfqxnoWA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2352,9 +2369,9 @@ } }, "node_modules/mocha": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.0.1.tgz", - "integrity": "sha512-+3GkODfsDG71KSCQhc4IekSW+ItCK/kiez1Z28ksWvYhKXV/syxMlerR/sC7whDp7IyreZ4YxceMLdTs5hQE8A==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.1.0.tgz", + "integrity": "sha512-8uJR5RTC2NgpY3GrYcgpZrsEd9zKbPDpob1RezyR2upGHRQtHWofmzTMzTMSV6dru3tj5Ukt0+Vnq1qhFEEwAg==", "dev": true, "license": "MIT", "dependencies": { @@ -2375,8 +2392,8 @@ "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", "workerpool": "^6.5.1", - "yargs": "^16.2.0", - "yargs-parser": "^20.2.9", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" }, "bin": { @@ -2608,9 +2625,9 @@ } }, "node_modules/prettier": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", - "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz", + "integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==", "dev": true, "license": "MIT", "bin": { @@ -2745,32 +2762,32 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-beta.1-commit.7c52c94", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.1-commit.7c52c94.tgz", - "integrity": "sha512-WSkfhxZ/LMc6FkXhdMoOlyY7YsvxEC1ioqTIwceT7edoA1cIqkGY4pcaNtk1Ve/0hhTGFFPksbGlWW0avVwGQg==", + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.3.tgz", + "integrity": "sha512-DBpF1K8tSwU/0dQ7zL9BYcje0/GjO5lgfdEW0rHHFfGjGDh8TBVNlokfEXtdt/IoJOiTdtySfsrgarLJkZmZTQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "0.45.0", - "@valibot/to-json-schema": "1.0.0-beta.3", - "valibot": "1.0.0-beta.9" + "@oxc-project/types": "0.46.0", + "@valibot/to-json-schema": "1.0.0-beta.4", + "valibot": "1.0.0-beta.12" }, "bin": { "rolldown": "bin/cli.js" }, "optionalDependencies": { - "@rolldown/binding-darwin-arm64": "1.0.0-beta.1-commit.7c52c94", - "@rolldown/binding-darwin-x64": "1.0.0-beta.1-commit.7c52c94", - "@rolldown/binding-freebsd-x64": "1.0.0-beta.1-commit.7c52c94", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.1-commit.7c52c94", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.1-commit.7c52c94", - "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.1-commit.7c52c94", - "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.1-commit.7c52c94", - "@rolldown/binding-linux-x64-musl": "1.0.0-beta.1-commit.7c52c94", - "@rolldown/binding-wasm32-wasi": "1.0.0-beta.1-commit.7c52c94", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.1-commit.7c52c94", - "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.1-commit.7c52c94", - "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.1-commit.7c52c94" + "@rolldown/binding-darwin-arm64": "1.0.0-beta.3", + "@rolldown/binding-darwin-x64": "1.0.0-beta.3", + "@rolldown/binding-freebsd-x64": "1.0.0-beta.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.3", + "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.3", + "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.3", + "@rolldown/binding-linux-x64-musl": "1.0.0-beta.3", + "@rolldown/binding-wasm32-wasi": "1.0.0-beta.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.3", + "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.3", + "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.3" }, "peerDependencies": { "@babel/runtime": ">=7" @@ -2844,9 +2861,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true, "license": "ISC", "bin": { @@ -3093,16 +3110,16 @@ } }, "node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, "node_modules/tslib": { @@ -3126,9 +3143,9 @@ } }, "node_modules/typescript": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", - "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3140,15 +3157,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.18.2.tgz", - "integrity": "sha512-KuXezG6jHkvC3MvizeXgupZzaG5wjhU3yE8E7e6viOvAvD9xAWYp8/vy0WULTGe9DYDWcQu7aW03YIV3mSitrQ==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.24.1.tgz", + "integrity": "sha512-cw3rEdzDqBs70TIcb0Gdzbt6h11BSs2pS0yaq7hDWDBtCCSei1pPSUXE9qUdQ/Wm9NgFg8mKtMt1b8fTHIl1jA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.18.2", - "@typescript-eslint/parser": "8.18.2", - "@typescript-eslint/utils": "8.18.2" + "@typescript-eslint/eslint-plugin": "8.24.1", + "@typescript-eslint/parser": "8.24.1", + "@typescript-eslint/utils": "8.24.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3190,9 +3207,9 @@ } }, "node_modules/valibot": { - "version": "1.0.0-beta.9", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.0.0-beta.9.tgz", - "integrity": "sha512-yEX8gMAZ2R1yI2uwOO4NCtVnJQx36zn3vD0omzzj9FhcoblvPukENIiRZXKZwCnqSeV80bMm8wNiGhQ0S8fiww==", + "version": "1.0.0-beta.12", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.0.0-beta.12.tgz", + "integrity": "sha512-j3WIxJ0pmUFMfdfUECn3YnZPYOiG0yHYcFEa/+RVgo0I+MXE3ToLt7gNRLtY5pwGfgNmsmhenGZfU5suu9ijUA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3350,32 +3367,32 @@ } }, "node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^7.0.2", + "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", - "string-width": "^4.2.0", + "string-width": "^4.2.3", "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" + "yargs-parser": "^21.1.1" }, "engines": { - "node": ">=10" + "node": ">=12" } }, "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, "license": "ISC", "engines": { - "node": ">=10" + "node": ">=12" } }, "node_modules/yargs-unparser": { diff --git a/package.json b/package.json index 6ce6e81..0aa3e59 100755 --- a/package.json +++ b/package.json @@ -41,25 +41,25 @@ "webassembly" ], "devDependencies": { - "@eslint/js": "9.17.0", - "@types/node": "22.10.2", - "@typescript-eslint/parser": "8.18.2", - "chai": "5.1.2", + "@eslint/js": "9.20.0", + "@types/node": "22.13.4", + "@typescript-eslint/parser": "8.24.1", + "chai": "5.2.0", "chai-as-promised": "8.0.1", - "eslint": "9.17.0", - "eslint-config-prettier": "9.1.0", - "eslint-plugin-prettier": "5.2.1", + "eslint": "9.20.1", + "eslint-config-prettier": "10.0.1", + "eslint-plugin-prettier": "5.2.3", "eslint-plugin-simple-import-sort": "12.1.1", "fengari": "0.1.4", - "mocha": "11.0.1", - "prettier": "3.4.2", - "rolldown": "1.0.0-beta.1-commit.7c52c94", + "mocha": "11.1.0", + "prettier": "3.5.1", + "rolldown": "1.0.0-beta.3", "rollup-plugin-copy": "3.5.0", "tslib": "2.8.1", - "typescript": "5.7.2", - "typescript-eslint": "8.18.2" + "typescript": "5.7.3", + "typescript-eslint": "8.24.1" }, "dependencies": { - "@types/emscripten": "1.39.10" + "@types/emscripten": "1.40.0" } } diff --git a/rolldown.config.ts b/rolldown.config.ts index 2dfbc28..a8db3a5 100644 --- a/rolldown.config.ts +++ b/rolldown.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ sourcemap: true, minify: production, }, - external: ['module'], + external: ['module', 'node:fs'], define: { // Webpack workaround: https://github.com/webpack/webpack/issues/16878 'import.meta': 'Object(import.meta)', diff --git a/src/factory.ts b/src/factory.ts index 0ef86d7..b3a45ad 100755 --- a/src/factory.ts +++ b/src/factory.ts @@ -15,6 +15,7 @@ export default class LuaFactory { * @param opts.wasmFile - Custom URI for the Lua WebAssembly module. * @param opts.env - Environment variables for the Lua engine. * @param opts.stdin - Standard input for the Lua engine. + * @param opts.fs - File system that should be used for the Lua engine. * @param opts.stdout - Standard output for the Lua engine. * @param opts.stderr - Standard error for the Lua engine. */ diff --git a/src/luawasm.ts b/src/luawasm.ts index 032ba9e..75f10fa 100755 --- a/src/luawasm.ts +++ b/src/luawasm.ts @@ -27,17 +27,33 @@ export default class LuaWasm { public static async initialize(opts: { wasmFile?: string env?: EnvironmentVariables + fs?: 'node' | 'memory' stdin?: () => number | null stdout?: () => number | null stderr?: () => number | null }): Promise { + const fs = opts.fs === 'node' && typeof process !== 'undefined' ? await import('node:fs') : null + const module: LuaEmscriptenModule = await initWasmModule({ locateFile: (path: string, scriptDirectory: string) => { return opts.wasmFile || scriptDirectory + path }, - preRun: (initializedModule: LuaEmscriptenModule) => { + preRun: (initializedModule: any) => { if (typeof opts?.env === 'object') { - Object.entries(opts.env).forEach(([k, v]) => (initializedModule.ENV[k] = v)) + Object.assign(initializedModule.ENV, opts.env) + } + + if (fs) { + const rootdirs = fs + .readdirSync('/') + .filter((dir) => !['dev', 'lib', 'proc'].includes(dir)) + .map((dir) => `/${dir}`) + + for (const dir of rootdirs) { + initializedModule.FS.mkdirTree(dir) + initializedModule.FS.mount(initializedModule.FS.filesystems.NODEFS, { root: dir }, dir) + } + initializedModule.FS.chdir(process.cwd()) } initializedModule.FS.init(opts?.stdin ?? null, opts?.stdout ?? null, opts?.stderr ?? null) diff --git a/test/initialization.test.js b/test/initialization.test.js index 72d1684..c0a4026 100644 --- a/test/initialization.test.js +++ b/test/initialization.test.js @@ -19,7 +19,7 @@ describe('Initialization', () => { const env = { ENV_TEST: 'test', } - const engine = await new LuaFactory(undefined, env).createEngine() + const engine = await new LuaFactory({ env }).createEngine() const value = await engine.doString('return os.getenv("ENV_TEST")') diff --git a/test/luatests.js b/test/luatests.js index 3b08dd2..681266e 100644 --- a/test/luatests.js +++ b/test/luatests.js @@ -1,25 +1,12 @@ import { LuaFactory } from '../dist/index.js' import { fileURLToPath } from 'node:url' -import { readFile, readdir } from 'node:fs/promises' -import { resolve } from 'node:path' - -async function* walk(dir) { - const dirents = await readdir(dir, { withFileTypes: true }) - for (const dirent of dirents) { - const res = resolve(dir, dirent.name) - if (dirent.isDirectory()) { - yield* walk(res) - } else { - yield res - } - } -} +import { readFile, glob } from 'node:fs/promises' const factory = new LuaFactory() const testsPath = import.meta.resolve('../lua/testes') const filePath = fileURLToPath(typeof testsPath === 'string' ? testsPath : await Promise.resolve(testsPath)) -for await (const file of walk(filePath)) { +for await (const file of glob(`${filePath}/**/*.lua`)) { const relativeFile = file.replace(`${filePath}/`, '') await factory.mountFile(relativeFile, await readFile(file)) } diff --git a/test/utils.js b/test/utils.js index 0bd5368..3106620 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,7 +1,7 @@ import { LuaFactory } from '../dist/index.js' export const getFactory = (env) => { - return new LuaFactory(undefined, env) + return new LuaFactory({ env }) } export const getEngine = (config = {}) => { diff --git a/tsconfig.json b/tsconfig.json index 14dbd93..625eae6 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,12 @@ { "compilerOptions": { "incremental": false, - "moduleResolution": "node", + "module": "ES2022", + "moduleResolution": "Bundler", "inlineSources": false, "removeComments": false, "sourceMap": false, - "target": "ES2018", + "target": "ES2022", "skipLibCheck": true, "lib": ["ESNEXT", "DOM"], "forceConsistentCasingInFileNames": true, From 2e0ab5f9de25ce260c5566ccfd70f8f1eac5ff71 Mon Sep 17 00:00:00 2001 From: ceifa Date: Tue, 18 Feb 2025 09:18:59 -0300 Subject: [PATCH 05/52] drop support for node 18 and 20 --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 50e5cc3..119e830 100755 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,13 +10,13 @@ jobs: strategy: matrix: - node-version: [18, 20, 22] + node-version: [22] steps: - uses: actions/checkout@v4 with: submodules: recursive - - uses: mymindstorm/setup-emsdk@v12 + - uses: mymindstorm/setup-emsdk@v14 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: From 580e25abf5bfa4cbe452c22d9a729c06f7aa52ec Mon Sep 17 00:00:00 2001 From: ceifa Date: Tue, 18 Feb 2025 10:35:26 -0300 Subject: [PATCH 06/52] unify build command --- build-wasm.js | 41 +++++++++++++++++++++++++++++++++ package.json | 6 ++--- rolldown.config.ts | 3 --- build.sh => utils/build-wasm.sh | 6 ++--- 4 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 build-wasm.js rename build.sh => utils/build-wasm.sh (97%) mode change 100755 => 100644 diff --git a/build-wasm.js b/build-wasm.js new file mode 100644 index 0000000..eab40f8 --- /dev/null +++ b/build-wasm.js @@ -0,0 +1,41 @@ +import { execSync } from 'node:child_process' +import { resolve } from 'node:path' + +const isUnix = process.platform !== 'win32' +const rootdir = import.meta.dirname; +const args = process.argv.slice(2) + +const execute = (command) => { + console.log(`Running: ${command}`) + try { + execSync(command, { stdio: 'inherit' }) + } catch (error) { + console.error(`Error running command: ${command}`) + process.exit(1) + } +} + +execute('git submodule update --init --recursive') + +if (isUnix) { + let emccInstalled = false + try { + const version = execSync('emcc --version', { encoding: 'utf-8' }) + console.log('Emscripten is installed:', version) + + emccInstalled = true + } catch (error) { + console.error('Emscripten is not installed or not in your PATH. Will try to build using Docker.') + } + + if (emccInstalled) { + const command = `${resolve(rootdir, 'utils/build-wasm.sh')} ${args.join(' ')}` + execute(command) + } +} + + +const dockerVolume = `${rootdir}:/wasmoon` +const command = `docker run --rm -v "${dockerVolume}" emscripten/emsdk /wasmoon/utils/build-wasm.sh ${args.join(' ')}` + +execute(command) diff --git a/package.json b/package.json index 0aa3e59..d9a0bd9 100755 --- a/package.json +++ b/package.json @@ -5,10 +5,8 @@ "main": "./dist/index.js", "type": "module", "scripts": { - "build:wasm:dev": "./build.sh dev", - "build:wasm": "./build.sh", - "build:wasm:docker:dev": "docker run --rm -v $(pwd):/wasmoon emscripten/emsdk /wasmoon/build.sh dev", - "build:wasm:docker": "docker run --rm -v $(pwd):/wasmoon emscripten/emsdk /wasmoon/build.sh", + "build:wasm:dev": "node build-wasm dev", + "build:wasm": "node build-wasm", "start": "rolldown -w -c", "test": "mocha --parallel --require ./test/boot.js test/*.test.js", "luatests": "node --experimental-import-meta-resolve test/luatests.js", diff --git a/rolldown.config.ts b/rolldown.config.ts index a8db3a5..4ecd699 100644 --- a/rolldown.config.ts +++ b/rolldown.config.ts @@ -2,15 +2,12 @@ import { defineConfig } from 'rolldown' import copy from 'rollup-plugin-copy' import pkg from './package.json' with { type: 'json' } -const production = !process.env.ROLLUP_WATCH - export default defineConfig({ input: './src/index.ts', output: { file: 'dist/index.js', format: 'esm', sourcemap: true, - minify: production, }, external: ['module', 'node:fs'], define: { diff --git a/build.sh b/utils/build-wasm.sh old mode 100755 new mode 100644 similarity index 97% rename from build.sh rename to utils/build-wasm.sh index 252517e..3b6d9f5 --- a/build.sh +++ b/utils/build-wasm.sh @@ -1,8 +1,8 @@ #!/bin/bash -e cd $(dirname $0) -mkdir -p build +mkdir -p ../build -LUA_SRC=$(ls ./lua/*.c | grep -v "luac.c" | grep -v "lua.c" | tr "\n" " ") +LUA_SRC=$(ls ../lua/*.c | grep -v "luac.c" | grep -v "lua.c" | tr "\n" " ") extension="" if [ "$1" == "dev" ]; @@ -14,7 +14,7 @@ fi emcc \ -lnodefs.js \ - -s WASM=1 $extension -o ./build/glue.js \ + -s WASM=1 $extension -o ../build/glue.js \ -s EXPORTED_RUNTIME_METHODS="[ 'ccall', \ 'addFunction', \ From 32fdd5c859e513ea834ad60ba26aaf6afd43837c Mon Sep 17 00:00:00 2001 From: ceifa Date: Mon, 24 Feb 2025 21:05:59 -0300 Subject: [PATCH 07/52] specify cjs --- utils/{create-bindings.js => create-bindings.cjs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename utils/{create-bindings.js => create-bindings.cjs} (100%) mode change 100755 => 100644 diff --git a/utils/create-bindings.js b/utils/create-bindings.cjs old mode 100755 new mode 100644 similarity index 100% rename from utils/create-bindings.js rename to utils/create-bindings.cjs From 9fdcef43e0df4f0366b60bb8eeda127441b76561 Mon Sep 17 00:00:00 2001 From: ceifa Date: Mon, 24 Feb 2025 21:06:14 -0300 Subject: [PATCH 08/52] add node fs support for windows --- rolldown.config.ts | 2 +- src/luawasm.ts | 42 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/rolldown.config.ts b/rolldown.config.ts index 4ecd699..5186a59 100644 --- a/rolldown.config.ts +++ b/rolldown.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ format: 'esm', sourcemap: true, }, - external: ['module', 'node:fs'], + external: ['module', 'node:fs', 'node:child_process'], define: { // Webpack workaround: https://github.com/webpack/webpack/issues/16878 'import.meta': 'Object(import.meta)', diff --git a/src/luawasm.ts b/src/luawasm.ts index 75f10fa..84d4cdd 100755 --- a/src/luawasm.ts +++ b/src/luawasm.ts @@ -33,6 +33,7 @@ export default class LuaWasm { stderr?: () => number | null }): Promise { const fs = opts.fs === 'node' && typeof process !== 'undefined' ? await import('node:fs') : null + const child_process = opts.fs === 'node' && typeof process !== 'undefined' ? await import('node:child_process') : null const module: LuaEmscriptenModule = await initWasmModule({ locateFile: (path: string, scriptDirectory: string) => { @@ -43,17 +44,42 @@ export default class LuaWasm { Object.assign(initializedModule.ENV, opts.env) } - if (fs) { - const rootdirs = fs - .readdirSync('/') - .filter((dir) => !['dev', 'lib', 'proc'].includes(dir)) - .map((dir) => `/${dir}`) + if (fs && child_process) { + let rootdirs: string[] + if (process.platform === 'win32') { + const stdout = child_process.execSync('wmic logicaldisk get name', { encoding: 'utf8' }) + const drives = stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && line !== 'Name') + .map((line) => line + '\\') + + rootdirs = [] + for (const drive of drives) { + rootdirs.push( + ...fs + .readdirSync(drive) + .filter((dir) => !['dev', 'lib', 'proc'].includes(dir)) + .map((dir) => `${drive}${dir}`.replace(/\\|\\\\/g, '/')), + ) + } + } else { + rootdirs = fs + .readdirSync('/') + .filter((dir) => !['dev', 'lib', 'proc'].includes(dir)) + .map((dir) => `/${dir}`) + } for (const dir of rootdirs) { - initializedModule.FS.mkdirTree(dir) - initializedModule.FS.mount(initializedModule.FS.filesystems.NODEFS, { root: dir }, dir) + try { + initializedModule.FS.mkdirTree(dir) + initializedModule.FS.mount(initializedModule.FS.filesystems.NODEFS, { root: dir }, dir) + } catch { + // silently fail to mount (generally due to EPERM) + } } - initializedModule.FS.chdir(process.cwd()) + + initializedModule.FS.chdir(process.cwd().replace(/\\|\\\\/g, '/')) } initializedModule.FS.init(opts?.stdin ?? null, opts?.stdout ?? null, opts?.stderr ?? null) From dbda8f06179373c51cd3b45c43f26e77e29998d7 Mon Sep 17 00:00:00 2001 From: ceifa Date: Mon, 24 Feb 2025 21:06:24 -0300 Subject: [PATCH 09/52] more close to real api --- bin/wasmoon | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/bin/wasmoon b/bin/wasmoon index 2abc218..1025970 100755 --- a/bin/wasmoon +++ b/bin/wasmoon @@ -1,8 +1,7 @@ #!/usr/bin/env node -import { LuaFactory, LuaReturn, LuaType, LUA_MULTRET, LuaRawResult, decorate, decorateFunction } from '../dist/index.js' +import { LuaFactory, LuaReturn, LuaType, LUA_MULTRET, decorate } from '../dist/index.js' import pkg from '../package.json' with { type: 'json' } import fs from 'node:fs' -import path from 'node:path' import readline from 'node:readline' function printUsage() { @@ -146,18 +145,13 @@ lua.global.set('arg', decorate(scriptArgs, { disableProxy: true })) const isTTY = process.stdin.isTTY -if (scriptFile) { - if (scriptFile === '-') { - const input = fs.readFileSync(0, 'utf-8') - await lua.doString(input) - console.log(lua.global.indexToString(-1)) - } else { - const absolutePath = path.resolve(process.cwd(), scriptFile) - const content = await fs.promises.readFile(absolutePath) - await factory.mountFile(absolutePath, content) - await lua.doFile(absolutePath) - console.log(lua.global.indexToString(-1)) - } +if (scriptFile === '-' || (!scriptFile && !isTTY)) { + const input = fs.readFileSync(0, 'utf-8') + await lua.doString(input) + console.log(lua.global.indexToString(-1)) +} else if (scriptFile) { + await lua.doFile(scriptFile) + console.log(lua.global.indexToString(-1)) } const shouldInteractive = isTTY && (forceInteractive || (!scriptFile && !executeSnippets.length)) From d9a63f9937e6552ea9554ad0bc17654e6dea2b49 Mon Sep 17 00:00:00 2001 From: ceifa Date: Mon, 24 Feb 2025 21:07:12 -0300 Subject: [PATCH 10/52] remove finalization registry check --- src/type-extensions/function.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/type-extensions/function.ts b/src/type-extensions/function.ts index 753ee90..1972dc3 100644 --- a/src/type-extensions/function.ts +++ b/src/type-extensions/function.ts @@ -23,14 +23,11 @@ export interface FunctionTypeExtensionOptions { } class FunctionTypeExtension extends TypeExtension { - private readonly functionRegistry = - typeof FinalizationRegistry !== 'undefined' - ? new FinalizationRegistry((func: number) => { - if (!this.thread.isClosed()) { - this.thread.lua.luaL_unref(this.thread.address, LUA_REGISTRYINDEX, func) - } - }) - : undefined + private readonly functionRegistry = new FinalizationRegistry((func: number) => { + if (!this.thread.isClosed()) { + this.thread.lua.luaL_unref(this.thread.address, LUA_REGISTRYINDEX, func) + } + }) private gcPointer: number private functionWrapper: number From 1e04deb680e018e488f3bfe1ea0bfbb875116591 Mon Sep 17 00:00:00 2001 From: ceifa Date: Mon, 24 Feb 2025 22:52:46 -0300 Subject: [PATCH 11/52] stdin finally working (at least on linux) --- bin/wasmoon | 91 ++++++++++++++++++++++++++++++++------------- utils/build-wasm.sh | 0 2 files changed, 65 insertions(+), 26 deletions(-) mode change 100644 => 100755 utils/build-wasm.sh diff --git a/bin/wasmoon b/bin/wasmoon index 1025970..3dd5324 100755 --- a/bin/wasmoon +++ b/bin/wasmoon @@ -18,7 +18,7 @@ Available options are: -W turn warnings on -- stop handling options - stop handling options and execute stdin -`.trim(), +`.trim() ) process.exit(1) } @@ -48,19 +48,15 @@ function parseArgs(args) { case '-v': showVersion = true break - case '-W': warnings = true break - case '-E': ignoreEnv = true break - case '-i': forceInteractive = true break - case '-e': i++ if (i >= args.length) { @@ -69,7 +65,6 @@ function parseArgs(args) { } executeSnippets.push(args[i]) break - case '-l': i++ if (i >= args.length) { @@ -78,12 +73,10 @@ function parseArgs(args) { } includeModules.push(args[i]) break - case '-': scriptFile = '-' i++ break - default: console.log(`unrecognized option: '${arg}'`) printUsage() @@ -110,11 +103,70 @@ function parseArgs(args) { } } -const { executeSnippets, includeModules, ignoreEnv, forceInteractive, warnings, showVersion, scriptFile, scriptArgs } = parseArgs( - process.argv.slice(2), -) +const { + executeSnippets, + includeModules, + ignoreEnv, + forceInteractive, + warnings, + showVersion, + scriptFile, + scriptArgs, +} = parseArgs(process.argv.slice(2)) + +// When running interactively, process.stdin is used by readline. +// To allow Lua’s io.read to work (even in interactive mode), we open +// a separate file descriptor to the terminal (e.g. '/dev/tty' on Unix). +let inputFD = 0 +if (process.stdin.isTTY) { + try { + inputFD = fs.openSync('/dev/tty', 'r') + } catch (e) { + // If opening /dev/tty fails (or on non-Unix systems), fallback to fd 0. + inputFD = 0 + } +} + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + removeHistoryDuplicates: true, + prompt: '> ' +}) + +let isresuming = false +const factory = new LuaFactory({ + env: ignoreEnv ? undefined : process.env, + fs: 'node', + stdin: () => { + if (isresuming) { + isresuming = false + return null + } else { + rl.pause() + } + + const buffer = Buffer.alloc(1) + const bytesRead = fs.readSync(inputFD, buffer, 0, buffer.length) + if (bytesRead > 0) { + const content = buffer.toString('utf8') + process.stdout.write(content) + + const charcode = content.charCodeAt(0) + if (charcode === 13) { + rl.resume() + isresuming = true + return null + } + + return charcode + } else { + return null + } + } +}) -const factory = new LuaFactory({ env: ignoreEnv ? undefined : process.env, fs: 'node' }) const luamodule = await factory.getLuaModule() const lua = await factory.createEngine() @@ -132,7 +184,6 @@ for (const module of includeModules) { if (!mod) { mod = global } - const require = lua.global.get('require') lua.global.set(global, require(mod)) } @@ -145,17 +196,14 @@ lua.global.set('arg', decorate(scriptArgs, { disableProxy: true })) const isTTY = process.stdin.isTTY -if (scriptFile === '-' || (!scriptFile && !isTTY)) { +if (scriptFile === '-') { const input = fs.readFileSync(0, 'utf-8') await lua.doString(input) - console.log(lua.global.indexToString(-1)) } else if (scriptFile) { await lua.doFile(scriptFile) - console.log(lua.global.indexToString(-1)) } const shouldInteractive = isTTY && (forceInteractive || (!scriptFile && !executeSnippets.length)) - if (shouldInteractive) { // Bypass result verification for interactive mode const loadcode = (code) => { @@ -168,14 +216,6 @@ if (shouldInteractive) { console.log(`Welcome to Wasmoon ${version} (${luaversion})`) console.log('Type Lua code and press Enter to execute. Ctrl+C to exit.\n') - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: true, - removeHistoryDuplicates: true, - prompt: '> ', - }) - rl.prompt() for await (const line of rl) { @@ -210,5 +250,4 @@ if (shouldInteractive) { // read from stdin until EOF. (Non-TTY or no -i, no file). const input = fs.readFileSync(0, 'utf-8') await lua.doString(input) - console.log(lua.global.indexToString(-1)) } diff --git a/utils/build-wasm.sh b/utils/build-wasm.sh old mode 100644 new mode 100755 From 048878a833c6bac5256258729ec754d1fa630b7a Mon Sep 17 00:00:00 2001 From: ceifa Date: Tue, 25 Feb 2025 08:00:39 -0300 Subject: [PATCH 12/52] add jsr publish --- .github/workflows/publish.yml | 101 ++++++++++++++++++++++++++++++---- 1 file changed, 90 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4aa8198..770a619 100755 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,26 +3,105 @@ name: CI on: push: branches: [main] + pull_request: + branches: [main] jobs: - publish: + build: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 with: submodules: recursive - - uses: mymindstorm/setup-emsdk@v14 + + - name: Setup EMSDK + uses: mymindstorm/setup-emsdk@v14 + - name: Use Node.js 22.x uses: actions/setup-node@v4 with: node-version: 22.x - - run: npm ci - - run: npm run lint:nofix - - run: npm run build:wasm - - run: npm run build - - run: npm test - - run: npm run luatests - - uses: JS-DevTools/npm-publish@v3 + + - name: Install dependencies + run: npm ci + + - name: Run lint (no fix) + run: npm run lint:nofix + + - name: Build WASM + run: npm run build:wasm + + - name: Build project + run: npm run build + + - name: Run tests + run: npm test + + - name: Run Lua tests + run: npm run luatests + + - name: Upload build artifact + uses: actions/upload-artifact@v3 + with: + name: build-artifact + path: | + package.json + package-lock.json + dist/ + wasm/ + + publish_npm: + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download build artifact + uses: actions/download-artifact@v3 + with: + name: build-artifact + path: build-artifact + + - name: Restore build artifact + run: cp -r build-artifact/* . + + - name: Publish to npm + uses: JS-DevTools/npm-publish@v3 with: token: ${{ secrets.NPM_TOKEN }} + + publish_jsr: + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download build artifact + uses: actions/download-artifact@v3 + with: + name: build-artifact + path: build-artifact + + - name: Restore build artifact + run: cp -r build-artifact/* . + + - name: Generate jsr.json from package.json + run: | + node -e "const pkg = require('./package.json'); \ + const jsr = { \ + name: pkg.name, \ + version: pkg.version, \ + description: pkg.description, \ + repository: pkg.repository, \ + license: pkg.license \ + }; \ + require('fs').writeFileSync('jsr.json', JSON.stringify(jsr, null, 2));" + + - name: Publish to JSR + run: npx jsr publish From 07096b3fd8d7130c17d1ef3379c49d98c2e430d4 Mon Sep 17 00:00:00 2001 From: ceifa Date: Tue, 25 Feb 2025 08:04:23 -0300 Subject: [PATCH 13/52] update artifact package --- .github/workflows/publish.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 770a619..eaf7ed8 100755 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -42,14 +42,14 @@ jobs: run: npm run luatests - name: Upload build artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: build-artifact path: | package.json package-lock.json dist/ - wasm/ + bin/ publish_npm: runs-on: ubuntu-latest @@ -59,7 +59,7 @@ jobs: uses: actions/checkout@v4 - name: Download build artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: build-artifact path: build-artifact @@ -83,7 +83,7 @@ jobs: uses: actions/checkout@v4 - name: Download build artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: build-artifact path: build-artifact From 26839478857b3a8c90f7dba1a0707bd6052feac5 Mon Sep 17 00:00:00 2001 From: ceifa Date: Tue, 25 Feb 2025 08:13:41 -0300 Subject: [PATCH 14/52] lint --- bin/wasmoon | 19 ++++++------------- package.json | 4 ++-- src/luawasm.ts | 2 +- build-wasm.js => utils/build-wasm.js | 5 +++-- 4 files changed, 12 insertions(+), 18 deletions(-) rename build-wasm.js => utils/build-wasm.js (93%) diff --git a/bin/wasmoon b/bin/wasmoon index 3dd5324..4c8cfbf 100755 --- a/bin/wasmoon +++ b/bin/wasmoon @@ -18,7 +18,7 @@ Available options are: -W turn warnings on -- stop handling options - stop handling options and execute stdin -`.trim() +`.trim(), ) process.exit(1) } @@ -103,16 +103,9 @@ function parseArgs(args) { } } -const { - executeSnippets, - includeModules, - ignoreEnv, - forceInteractive, - warnings, - showVersion, - scriptFile, - scriptArgs, -} = parseArgs(process.argv.slice(2)) +const { executeSnippets, includeModules, ignoreEnv, forceInteractive, warnings, showVersion, scriptFile, scriptArgs } = parseArgs( + process.argv.slice(2), +) // When running interactively, process.stdin is used by readline. // To allow Lua’s io.read to work (even in interactive mode), we open @@ -132,7 +125,7 @@ const rl = readline.createInterface({ output: process.stdout, terminal: true, removeHistoryDuplicates: true, - prompt: '> ' + prompt: '> ', }) let isresuming = false @@ -164,7 +157,7 @@ const factory = new LuaFactory({ } else { return null } - } + }, }) const luamodule = await factory.getLuaModule() diff --git a/package.json b/package.json index d9a0bd9..82f1c7a 100755 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "main": "./dist/index.js", "type": "module", "scripts": { - "build:wasm:dev": "node build-wasm dev", - "build:wasm": "node build-wasm", + "build:wasm:dev": "node utils/build-wasm dev", + "build:wasm": "node utils/build-wasm", "start": "rolldown -w -c", "test": "mocha --parallel --require ./test/boot.js test/*.test.js", "luatests": "node --experimental-import-meta-resolve test/luatests.js", diff --git a/src/luawasm.ts b/src/luawasm.ts index 84d4cdd..8902f64 100755 --- a/src/luawasm.ts +++ b/src/luawasm.ts @@ -52,7 +52,7 @@ export default class LuaWasm { .split('\n') .map((line) => line.trim()) .filter((line) => line && line !== 'Name') - .map((line) => line + '\\') + .map((line) => `${line}\\`) rootdirs = [] for (const drive of drives) { diff --git a/build-wasm.js b/utils/build-wasm.js similarity index 93% rename from build-wasm.js rename to utils/build-wasm.js index eab40f8..7a9b590 100644 --- a/build-wasm.js +++ b/utils/build-wasm.js @@ -2,7 +2,7 @@ import { execSync } from 'node:child_process' import { resolve } from 'node:path' const isUnix = process.platform !== 'win32' -const rootdir = import.meta.dirname; +const rootdir = resolve(import.meta.dirname, '..') const args = process.argv.slice(2) const execute = (command) => { @@ -31,10 +31,11 @@ if (isUnix) { if (emccInstalled) { const command = `${resolve(rootdir, 'utils/build-wasm.sh')} ${args.join(' ')}` execute(command) + + process.exit(0) } } - const dockerVolume = `${rootdir}:/wasmoon` const command = `docker run --rm -v "${dockerVolume}" emscripten/emsdk /wasmoon/utils/build-wasm.sh ${args.join(' ')}` From a315ababc7f0990d1c6ef9cbdc97f257738f62b1 Mon Sep 17 00:00:00 2001 From: ceifa Date: Tue, 25 Feb 2025 08:24:04 -0300 Subject: [PATCH 15/52] fix jsr.json structure --- .github/workflows/publish.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index eaf7ed8..e4c5f21 100755 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -97,9 +97,7 @@ jobs: const jsr = { \ name: pkg.name, \ version: pkg.version, \ - description: pkg.description, \ - repository: pkg.repository, \ - license: pkg.license \ + exports: pkg.main \ }; \ require('fs').writeFileSync('jsr.json', JSON.stringify(jsr, null, 2));" From 68deb27b4e079e0843505b1399da3325be46fc65 Mon Sep 17 00:00:00 2001 From: ceifa Date: Tue, 25 Feb 2025 08:26:08 -0300 Subject: [PATCH 16/52] package name --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e4c5f21..534ae7f 100755 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -95,7 +95,7 @@ jobs: run: | node -e "const pkg = require('./package.json'); \ const jsr = { \ - name: pkg.name, \ + name: `@ceifa/${pkg.name}`, \ version: pkg.version, \ exports: pkg.main \ }; \ From 84ee22ce695e75d66bdfaa276476705ce58ca81e Mon Sep 17 00:00:00 2001 From: ceifa Date: Tue, 25 Feb 2025 08:28:03 -0300 Subject: [PATCH 17/52] concatenation --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 534ae7f..4b12368 100755 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -95,7 +95,7 @@ jobs: run: | node -e "const pkg = require('./package.json'); \ const jsr = { \ - name: `@ceifa/${pkg.name}`, \ + name: '@ceifa/' + pkg.name, \ version: pkg.version, \ exports: pkg.main \ }; \ From 745fac60c45e04687039ac65b054f1e13aa60015 Mon Sep 17 00:00:00 2001 From: ceifa Date: Tue, 25 Feb 2025 08:37:30 -0300 Subject: [PATCH 18/52] force include --- .github/workflows/publish.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4b12368..9825163 100755 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -97,7 +97,8 @@ jobs: const jsr = { \ name: '@ceifa/' + pkg.name, \ version: pkg.version, \ - exports: pkg.main \ + exports: pkg.main, \ + include: ['dist/**/*', 'bin/**/*'] \ }; \ require('fs').writeFileSync('jsr.json', JSON.stringify(jsr, null, 2));" From 3b4d4e125d5d8271ff33cc292b665e0c7a5ce7bc Mon Sep 17 00:00:00 2001 From: ceifa Date: Tue, 25 Feb 2025 08:56:23 -0300 Subject: [PATCH 19/52] force exclude dist --- .github/workflows/publish.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9825163..0a8f53c 100755 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -98,7 +98,8 @@ jobs: name: '@ceifa/' + pkg.name, \ version: pkg.version, \ exports: pkg.main, \ - include: ['dist/**/*', 'bin/**/*'] \ + include: ['dist/**/*', 'bin/**/*'], \ + exclude: ['!dist/**/*'], \ }; \ require('fs').writeFileSync('jsr.json', JSON.stringify(jsr, null, 2));" From f8f95f5cb99f0597ab69ba1dd3c032c29c14b28d Mon Sep 17 00:00:00 2001 From: ceifa Date: Tue, 25 Feb 2025 08:59:07 -0300 Subject: [PATCH 20/52] try not checking out repo --- .github/workflows/publish.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0a8f53c..ac9ebe7 100755 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -79,9 +79,6 @@ jobs: contents: read id-token: write steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Download build artifact uses: actions/download-artifact@v4 with: @@ -98,8 +95,7 @@ jobs: name: '@ceifa/' + pkg.name, \ version: pkg.version, \ exports: pkg.main, \ - include: ['dist/**/*', 'bin/**/*'], \ - exclude: ['!dist/**/*'], \ + include: ['dist/**/*', 'bin/**/*'] }; \ require('fs').writeFileSync('jsr.json', JSON.stringify(jsr, null, 2));" From 8acf55d30b66aab2d5bdcc5bfe7b5d2dde383d01 Mon Sep 17 00:00:00 2001 From: ceifa Date: Tue, 25 Feb 2025 09:01:10 -0300 Subject: [PATCH 21/52] license --- .github/workflows/publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ac9ebe7..a7f4aa4 100755 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -50,6 +50,7 @@ jobs: package-lock.json dist/ bin/ + LICENSE publish_npm: runs-on: ubuntu-latest From b3778d6d89df5b5ab1a7bb24d75e24e1617727d5 Mon Sep 17 00:00:00 2001 From: ceifa Date: Tue, 25 Feb 2025 09:34:13 -0300 Subject: [PATCH 22/52] test older emscripten version --- .github/workflows/publish.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a7f4aa4..84bf493 100755 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,6 +17,8 @@ jobs: - name: Setup EMSDK uses: mymindstorm/setup-emsdk@v14 + with: + version: 4.0.1 - name: Use Node.js 22.x uses: actions/setup-node@v4 @@ -94,7 +96,7 @@ jobs: node -e "const pkg = require('./package.json'); \ const jsr = { \ name: '@ceifa/' + pkg.name, \ - version: pkg.version, \ + version: 1.16.1, \ exports: pkg.main, \ include: ['dist/**/*', 'bin/**/*'] }; \ From 657a0799d91f33d8a5ae3e4a72cada6c64610a03 Mon Sep 17 00:00:00 2001 From: ceifa Date: Tue, 25 Feb 2025 09:34:44 -0300 Subject: [PATCH 23/52] fix string --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 84bf493..2039fc8 100755 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -96,7 +96,7 @@ jobs: node -e "const pkg = require('./package.json'); \ const jsr = { \ name: '@ceifa/' + pkg.name, \ - version: 1.16.1, \ + version: '1.16.1', \ exports: pkg.main, \ include: ['dist/**/*', 'bin/**/*'] }; \ From 32af619d08ab3330e7e94430cd7056115ef0e8b6 Mon Sep 17 00:00:00 2001 From: ceifa Date: Tue, 25 Feb 2025 09:42:55 -0300 Subject: [PATCH 24/52] disable jsr publish --- .github/workflows/publish.yml | 61 +++++++++++++++++------------------ 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2039fc8..3dbc317 100755 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,8 +3,6 @@ name: CI on: push: branches: [main] - pull_request: - branches: [main] jobs: build: @@ -75,32 +73,33 @@ jobs: with: token: ${{ secrets.NPM_TOKEN }} - publish_jsr: - runs-on: ubuntu-latest - needs: build - permissions: - contents: read - id-token: write - steps: - - name: Download build artifact - uses: actions/download-artifact@v4 - with: - name: build-artifact - path: build-artifact - - - name: Restore build artifact - run: cp -r build-artifact/* . - - - name: Generate jsr.json from package.json - run: | - node -e "const pkg = require('./package.json'); \ - const jsr = { \ - name: '@ceifa/' + pkg.name, \ - version: '1.16.1', \ - exports: pkg.main, \ - include: ['dist/**/*', 'bin/**/*'] - }; \ - require('fs').writeFileSync('jsr.json', JSON.stringify(jsr, null, 2));" - - - name: Publish to JSR - run: npx jsr publish + # Emscripten still doesn't support deno + # publish_jsr: + # runs-on: ubuntu-latest + # needs: build + # permissions: + # contents: read + # id-token: write + # steps: + # - name: Download build artifact + # uses: actions/download-artifact@v4 + # with: + # name: build-artifact + # path: build-artifact + + # - name: Restore build artifact + # run: cp -r build-artifact/* . + + # - name: Generate jsr.json from package.json + # run: | + # node -e "const pkg = require('./package.json'); \ + # const jsr = { \ + # name: '@ceifa/' + pkg.name, \ + # version: pkg.version, \ + # exports: pkg.main, \ + # include: ['dist/**/*', 'bin/**/*'] + # }; \ + # require('fs').writeFileSync('jsr.json', JSON.stringify(jsr, null, 2));" + + # - name: Publish to JSR + # run: npx jsr publish From a34ed7448fa0d85f3b1d3ce217b8d28b46a7a515 Mon Sep 17 00:00:00 2001 From: ceifa Date: Wed, 26 Feb 2025 18:22:44 -0300 Subject: [PATCH 25/52] better api for stdio --- bin/wasmoon | 49 +++++++++++++++++++++++++++------------------ src/luawasm.ts | 46 +++++++++++++++++++++++++++++++++++------- utils/build-wasm.sh | 5 ++++- 3 files changed, 72 insertions(+), 28 deletions(-) diff --git a/bin/wasmoon b/bin/wasmoon index 4c8cfbf..a71cbed 100755 --- a/bin/wasmoon +++ b/bin/wasmoon @@ -128,34 +128,43 @@ const rl = readline.createInterface({ prompt: '> ', }) -let isresuming = false const factory = new LuaFactory({ env: ignoreEnv ? undefined : process.env, fs: 'node', stdin: () => { - if (isresuming) { - isresuming = false - return null - } else { + try { rl.pause() - } - const buffer = Buffer.alloc(1) - const bytesRead = fs.readSync(inputFD, buffer, 0, buffer.length) - if (bytesRead > 0) { - const content = buffer.toString('utf8') - process.stdout.write(content) - - const charcode = content.charCodeAt(0) - if (charcode === 13) { - rl.resume() - isresuming = true - return null + let buffer = Buffer.alloc(0xff) + let content = '' + let current = 0 + + while (true) { + const bytesRead = fs.readSync(inputFD, buffer, current, buffer.length - current) + if (bytesRead > 0) { + const charcode = buffer[current++] + if (charcode === 127) { + current = Math.max(0, current - 2) + } else { + if (charcode === 13) { + break + } + } + + content = buffer.subarray(0, current).toString('utf8') + process.stdout.write('\x1b[2K') + process.stdout.write('\x1b[0G') + process.stdout.write(content) + } else { + break + } } - return charcode - } else { - return null + rl.resume() + return content + } catch (err) { + // the error will not be thrown to the top, its better to log it + console.error(err) } }, }) diff --git a/src/luawasm.ts b/src/luawasm.ts index 8902f64..789373d 100755 --- a/src/luawasm.ts +++ b/src/luawasm.ts @@ -13,6 +13,7 @@ interface LuaEmscriptenModule extends EmscriptenModule { stringToNewUTF8: typeof allocateUTF8 lengthBytesUTF8: typeof lengthBytesUTF8 stringToUTF8: typeof stringToUTF8 + intArrayFromString: typeof intArrayFromString UTF8ToString: typeof UTF8ToString ENV: EnvironmentVariables _realloc: (pointer: number, size: number) => number @@ -28,18 +29,20 @@ export default class LuaWasm { wasmFile?: string env?: EnvironmentVariables fs?: 'node' | 'memory' - stdin?: () => number | null - stdout?: () => number | null - stderr?: () => number | null + stdin?: () => string + stdout?: (content: string) => void + stderr?: (content: string) => void }): Promise { const fs = opts.fs === 'node' && typeof process !== 'undefined' ? await import('node:fs') : null const child_process = opts.fs === 'node' && typeof process !== 'undefined' ? await import('node:child_process') : null const module: LuaEmscriptenModule = await initWasmModule({ + print: opts.stdout, + printErr: opts.stderr, locateFile: (path: string, scriptDirectory: string) => { return opts.wasmFile || scriptDirectory + path }, - preRun: (initializedModule: any) => { + preRun: (initializedModule: LuaEmscriptenModule) => { if (typeof opts?.env === 'object') { Object.assign(initializedModule.ENV, opts.env) } @@ -72,8 +75,9 @@ export default class LuaWasm { for (const dir of rootdirs) { try { - initializedModule.FS.mkdirTree(dir) - initializedModule.FS.mount(initializedModule.FS.filesystems.NODEFS, { root: dir }, dir) + const moduleFS = initializedModule.FS as any // emscripten FS module is not correctly typed + moduleFS.mkdirTree(dir) + moduleFS.mount(moduleFS.filesystems.NODEFS, { root: dir }, dir) } catch { // silently fail to mount (generally due to EPERM) } @@ -82,7 +86,35 @@ export default class LuaWasm { initializedModule.FS.chdir(process.cwd().replace(/\\|\\\\/g, '/')) } - initializedModule.FS.init(opts?.stdin ?? null, opts?.stdout ?? null, opts?.stderr ?? null) + if (opts.stdin) { + let bufferedInput: number[] | undefined + initializedModule.FS.init( + () => { + if (!opts.stdin) { + throw new Error('stdin is not defined, it was probably mutated from original options') + } + + if (!bufferedInput) { + const stdin = opts.stdin() + if (typeof stdin === 'string') { + bufferedInput = initializedModule.intArrayFromString(stdin, true).concat([0]) + } else { + throw new Error('stdin must return a string') + } + } + + if (bufferedInput.length === 0) { + bufferedInput = undefined + return null + } + + const item = bufferedInput.shift() + return !item || item === 0 ? null : item + }, + null, + null, + ) + } }, }) return new LuaWasm(module) diff --git a/utils/build-wasm.sh b/utils/build-wasm.sh index 3b6d9f5..6b18ceb 100755 --- a/utils/build-wasm.sh +++ b/utils/build-wasm.sh @@ -26,12 +26,15 @@ emcc \ 'lengthBytesUTF8', \ 'stringToUTF8', \ 'stringToNewUTF8', \ + 'intArrayFromString', \ 'UTF8ToString', \ 'HEAPU32' ]" \ -s INCOMING_MODULE_JS_API="[ 'locateFile', \ - 'preRun' + 'preRun', \ + 'print', \ + 'printErr' \ ]" \ -s ENVIRONMENT="web,worker,node" \ -s MODULARIZE=1 \ From e63fcb622ff56d09fae84fcec98a304c3314dd24 Mon Sep 17 00:00:00 2001 From: ceifa Date: Wed, 26 Feb 2025 18:25:25 -0300 Subject: [PATCH 26/52] check for docker --- utils/build-wasm.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/utils/build-wasm.js b/utils/build-wasm.js index 7a9b590..ff97b26 100644 --- a/utils/build-wasm.js +++ b/utils/build-wasm.js @@ -36,6 +36,14 @@ if (isUnix) { } } +try { + execSync('docker --version', { encoding: 'utf-8' }) +} +catch (error) { + console.error('Docker is not installed or not in your PATH. Please install Docker to build the WASM file.') + process.exit(1) +} + const dockerVolume = `${rootdir}:/wasmoon` const command = `docker run --rm -v "${dockerVolume}" emscripten/emsdk /wasmoon/utils/build-wasm.sh ${args.join(' ')}` From ecc6388bc0ff9594cf09069ec29380035d59941b Mon Sep 17 00:00:00 2001 From: ceifa Date: Wed, 26 Feb 2025 20:38:14 -0300 Subject: [PATCH 27/52] changed library api to abstract wasm (but keep it exposed) and change api of lua creation --- bench/comparisons.js | 74 +++++-- bench/heapsort.lua | 2 + bench/steps.js | 62 +++--- bin/wasmoon | 41 ++-- package.json | 1 + src/engine.ts | 8 +- src/factory.ts | 97 --------- src/global.ts | 16 +- src/index.ts | 7 +- src/lua.ts | 46 +++++ src/{luawasm.ts => module.ts} | 47 +++-- src/thread.ts | 26 +-- src/type-extension.ts | 4 +- src/type-extensions/error.ts | 6 +- src/type-extensions/function.ts | 14 +- src/type-extensions/null.ts | 6 +- src/type-extensions/promise.ts | 10 +- src/type-extensions/proxy.ts | 8 +- src/type-extensions/userdata.ts | 8 +- test/debug.js | 10 +- test/engine.test.js | 352 ++++++++++++++++---------------- test/filesystem.test.js | 52 ++--- test/initialization.test.js | 25 ++- test/luatests.js | 21 +- test/promises.test.js | 118 +++++------ test/utils.js | 11 +- utils/build-wasm.sh | 1 + 27 files changed, 544 insertions(+), 529 deletions(-) delete mode 100755 src/factory.ts create mode 100755 src/lua.ts rename src/{luawasm.ts => module.ts} (95%) diff --git a/bench/comparisons.js b/bench/comparisons.js index 49c7b1b..1a72e9a 100644 --- a/bench/comparisons.js +++ b/bench/comparisons.js @@ -1,30 +1,62 @@ -const { readFileSync } = require('fs') -const path = require('path') +import { readFileSync } from 'fs' +import path from 'path' +import { performance } from 'perf_hooks' +import { Lua } from '../dist/index.js' +import fengari from 'fengari' -const fengari = require('fengari') -const wasmoon = require('../dist/index') +const heapsort = readFileSync(path.resolve(import.meta.dirname, 'heapsort.lua'), 'utf-8') -const heapsort = readFileSync(path.resolve(__dirname, 'heapsort.lua'), 'utf-8') +function calculateStats(times) { + const n = times.length + const avg = times.reduce((sum, t) => sum + t, 0) / n + const stdDev = Math.sqrt(times.reduce((sum, t) => sum + (t - avg) ** 2, 0) / n) + return { avg, stdDev } +} + +async function benchmark(name, iterations, warmup, fn) { + console.log(`\nBenchmarking ${name}...`) + + for (let i = 0; i < warmup; i++) { + await fn() + } -const startFengari = () => { - const state = fengari.lauxlib.luaL_newstate() - fengari.lualib.luaL_openlibs(state) + const times = [] + for (let i = 0; i < iterations; i++) { + const start = performance.now() + await fn() + const end = performance.now() + times.push(end - start) + } - console.time('Fengari') - fengari.lauxlib.luaL_loadstring(state, fengari.to_luastring(heapsort)) - fengari.lua.lua_callk(state, 0, 1, 0, null) - fengari.lua.lua_callk(state, 0, 0, 0, null) - console.timeEnd('Fengari') + const { avg, stdDev } = calculateStats(times) + console.log(`${name}: ${iterations} iterations | avg: ${avg.toFixed(3)} ms | std dev: ${stdDev.toFixed(3)} ms`) } -const startWasmoon = async () => { - const state = await new wasmoon.LuaFactory().createEngine() +async function benchmarkFengari(iterations, warmup) { + function runFengariIteration() { + const state = fengari.lauxlib.luaL_newstate() + fengari.lualib.luaL_openlibs(state) + fengari.lauxlib.luaL_loadstring(state, fengari.to_luastring(heapsort)) + fengari.lua.lua_callk(state, 0, 1, 0, null) + fengari.lua.lua_callk(state, 0, 0, 0, null) + } + await benchmark('Fengari', iterations, warmup, runFengariIteration) +} + +async function benchmarkWasmoon(iterations, warmup) { + const lua = await Lua.load() - console.time('Wasmoon') - state.global.lua.luaL_loadstring(state.global.address, heapsort) - state.global.lua.lua_callk(state.global.address, 0, 1, 0, null) - state.global.lua.lua_callk(state.global.address, 0, 0, 0, null) - console.timeEnd('Wasmoon') + async function runWasmoonIteration() { + const state = lua.createState() + state.global.lua.luaL_loadstring(state.global.address, heapsort) + state.global.lua.lua_callk(state.global.address, 0, 1, 0, null) + state.global.lua.lua_callk(state.global.address, 0, 0, 0, null) + } + await benchmark('Wasmoon', iterations, warmup, runWasmoonIteration) } -Promise.resolve().then(startFengari).then(startWasmoon).catch(console.error) +const iterations = 100 +const warmup = 10 + +await benchmarkFengari(iterations, warmup) +await benchmarkWasmoon(iterations, warmup) diff --git a/bench/heapsort.lua b/bench/heapsort.lua index 5e7ce00..026f651 100644 --- a/bench/heapsort.lua +++ b/bench/heapsort.lua @@ -57,4 +57,6 @@ return function() assert(a[i] <= a[i + 1]) end end + + return Num end diff --git a/bench/steps.js b/bench/steps.js index 9c670d5..9066fcd 100644 --- a/bench/steps.js +++ b/bench/steps.js @@ -1,44 +1,39 @@ -const { readFileSync } = require('fs') -const path = require('path') +import { readFileSync } from 'fs' +import path from 'path' +import { Lua } from '../dist/index.js' +import assert from 'node:assert' -const wasmoon = require('../dist/index') +const heapsort = readFileSync(path.resolve(import.meta.dirname, 'heapsort.lua'), 'utf-8') -const heapsort = readFileSync(path.resolve(__dirname, 'heapsort.lua'), 'utf-8') - -const createFactory = () => { +const createFactory = async () => { console.time('Create factory') - _ = new wasmoon.LuaFactory() + await Lua.load() console.timeEnd('Create factory') } -const loadWasm = async () => { - console.time('Load wasm') - await new wasmoon.LuaFactory().getLuaModule() - console.timeEnd('Load wasm') -} - -const createEngine = async () => { - const factory = new wasmoon.LuaFactory() +const createState = async () => { + const lua = await Lua.load() - console.time('Create engine') - await factory.createEngine() - console.timeEnd('Create engine') + console.time('Create state') + lua.createState() + console.timeEnd('Create state') } -const createEngineWithoutSuperpowers = async () => { - const factory = new wasmoon.LuaFactory() +const createStateWithoutSuperpowers = async () => { + const lua = await Lua.load() - console.time('Create engine without superpowers') - await factory.createEngine({ + console.time('Create state without superpowers') + lua.createState({ injectObjects: false, enableProxy: false, openStandardLibs: false, }) - console.timeEnd('Create engine without superpowers') + console.timeEnd('Create state without superpowers') } const runHeapsort = async () => { - const state = await new wasmoon.LuaFactory().createEngine() + const lua = await Lua.load() + const state = lua.createState() console.time('Run plain heapsort') state.global.lua.luaL_loadstring(state.global.address, heapsort) @@ -48,16 +43,18 @@ const runHeapsort = async () => { } const runInteropedHeapsort = async () => { - const state = await new wasmoon.LuaFactory().createEngine() + const lua = await Lua.load() + const state = lua.createState() console.time('Run interoped heapsort') const runHeapsort = await state.doString(heapsort) - runHeapsort() + assert(runHeapsort() === 10) console.timeEnd('Run interoped heapsort') } const insertComplexObjects = async () => { - const state = await new wasmoon.LuaFactory().createEngine() + const lua = await Lua.load() + const state = lua.createState() const obj1 = { hello: 'world', } @@ -77,7 +74,8 @@ const insertComplexObjects = async () => { } const insertComplexObjectsWithoutProxy = async () => { - const state = await new wasmoon.LuaFactory().createEngine({ + const lua = await Lua.load() + const state = lua.createState({ enableProxy: false, }) const obj1 = { @@ -99,7 +97,8 @@ const insertComplexObjectsWithoutProxy = async () => { } const getComplexObjects = async () => { - const state = await new wasmoon.LuaFactory().createEngine() + const lua = await Lua.load() + const state = lua.createState() await state.doString(` local obj1 = { hello = 'world', @@ -124,9 +123,8 @@ const getComplexObjects = async () => { Promise.resolve() .then(createFactory) - .then(loadWasm) - .then(createEngine) - .then(createEngineWithoutSuperpowers) + .then(createState) + .then(createStateWithoutSuperpowers) .then(runHeapsort) .then(runInteropedHeapsort) .then(insertComplexObjects) diff --git a/bin/wasmoon b/bin/wasmoon index a71cbed..db9cbb7 100755 --- a/bin/wasmoon +++ b/bin/wasmoon @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { LuaFactory, LuaReturn, LuaType, LUA_MULTRET, decorate } from '../dist/index.js' +import { Lua, LuaReturn, LuaType, LUA_MULTRET, decorate } from '../dist/index.js' import pkg from '../package.json' with { type: 'json' } import fs from 'node:fs' import readline from 'node:readline' @@ -128,7 +128,7 @@ const rl = readline.createInterface({ prompt: '> ', }) -const factory = new LuaFactory({ +const lua = await Lua.load({ env: ignoreEnv ? undefined : process.env, fs: 'node', stdin: () => { @@ -169,16 +169,15 @@ const factory = new LuaFactory({ }, }) -const luamodule = await factory.getLuaModule() -const lua = await factory.createEngine() +const state = lua.createState() if (showVersion) { - console.log(`wasmoon ${pkg.version} (${lua.global.get('_VERSION')})`) + console.log(`wasmoon ${pkg.version} (${state.global.get('_VERSION')})`) process.exit(0) } if (warnings) { - luamodule.lua_warning(lua.global.address, '@on', 0) + lua.module.lua_warning(state.global.address, '@on', 0) } for (const module of includeModules) { @@ -186,35 +185,35 @@ for (const module of includeModules) { if (!mod) { mod = global } - const require = lua.global.get('require') - lua.global.set(global, require(mod)) + const require = state.global.get('require') + state.global.set(global, require(mod)) } for (const snippet of executeSnippets) { - await lua.doString(snippet) + await state.doString(snippet) } -lua.global.set('arg', decorate(scriptArgs, { disableProxy: true })) +state.global.set('arg', decorate(scriptArgs, { disableProxy: true })) const isTTY = process.stdin.isTTY if (scriptFile === '-') { const input = fs.readFileSync(0, 'utf-8') - await lua.doString(input) + await state.doString(input) } else if (scriptFile) { - await lua.doFile(scriptFile) + await state.doFile(scriptFile) } const shouldInteractive = isTTY && (forceInteractive || (!scriptFile && !executeSnippets.length)) if (shouldInteractive) { // Bypass result verification for interactive mode const loadcode = (code) => { - lua.global.setTop(0) - return luamodule.luaL_loadstring(lua.global.address, code) === LuaReturn.Ok + state.global.setTop(0) + return lua.module.luaL_loadstring(state.global.address, code) === LuaReturn.Ok } const version = pkg.version - const luaversion = lua.global.get('_VERSION') + const luaversion = state.global.get('_VERSION') console.log(`Welcome to Wasmoon ${version} (${luaversion})`) console.log('Type Lua code and press Enter to execute. Ctrl+C to exit.\n') @@ -225,24 +224,24 @@ if (shouldInteractive) { const loaded = loadcode(`return ${line}`) || loadcode(line) if (!loaded) { // Failed to parse - const err = lua.global.getValue(-1, LuaType.String) + const err = state.global.getValue(-1, LuaType.String) console.log(err) rl.prompt() continue } - const result = luamodule.lua_pcallk(lua.global.address, 0, LUA_MULTRET, 0, 0, null) + const result = lua.module.lua_pcallk(state.global.address, 0, LUA_MULTRET, 0, 0, null) if (result === LuaReturn.Ok) { - const count = lua.global.getTop() + const count = state.global.getTop() if (count > 0) { const returnValues = [] for (let i = 1; i <= count; i++) { - returnValues.push(lua.global.indexToString(i)) + returnValues.push(state.global.indexToString(i)) } console.log(...returnValues) } } else { - console.log(lua.global.getValue(-1, LuaType.String)) + console.log(state.global.getValue(-1, LuaType.String)) } rl.prompt() @@ -251,5 +250,5 @@ if (shouldInteractive) { // If we're not interactive, and we did NOT run a file, // read from stdin until EOF. (Non-TTY or no -i, no file). const input = fs.readFileSync(0, 'utf-8') - await lua.doString(input) + await state.doString(input) } diff --git a/package.json b/package.json index 82f1c7a..746bc87 100755 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "1.16.0", "description": "A real lua VM with JS bindings made with webassembly", "main": "./dist/index.js", + "types": "./dist/index.d.ts", "type": "module", "scripts": { "build:wasm:dev": "node utils/build-wasm dev", diff --git a/src/engine.ts b/src/engine.ts index 59ab415..440e91d 100755 --- a/src/engine.ts +++ b/src/engine.ts @@ -1,6 +1,6 @@ import { CreateEngineOptions } from './types' import Global from './global' -import type LuaWasm from './luawasm' +import type LuaModule from './module' import Thread from './thread' import createErrorType from './type-extensions/error' import createFunctionType from './type-extensions/function' @@ -14,7 +14,7 @@ export default class LuaEngine { public global: Global public constructor( - private cmodule: LuaWasm, + private module: LuaModule, { openStandardLibs = true, injectObjects = false, @@ -23,7 +23,7 @@ export default class LuaEngine { functionTimeout = undefined as number | undefined, }: CreateEngineOptions = {}, ) { - this.global = new Global(this.cmodule, traceAllocations) + this.global = new Global(this.module, traceAllocations) // Generic handlers - These may be required to be registered for additional types. this.global.registerTypeExtension(0, createTableType(this.global)) @@ -52,7 +52,7 @@ export default class LuaEngine { this.global.registerTypeExtension(4, createUserdataType(this.global)) if (openStandardLibs) { - this.cmodule.luaL_openlibs(this.global.address) + this.module.luaL_openlibs(this.global.address) } } diff --git a/src/factory.ts b/src/factory.ts deleted file mode 100755 index b3a45ad..0000000 --- a/src/factory.ts +++ /dev/null @@ -1,97 +0,0 @@ -// A rollup plugin will resolve this to the current version on package.json -import version from 'package-version' -import LuaEngine from './engine' -import LuaWasm from './luawasm' -import { CreateEngineOptions } from './types' - -/** - * Represents a factory for creating and configuring Lua engines. - */ -export default class LuaFactory { - private luaWasmPromise: Promise - - /** - * Constructs a new LuaFactory instance. - * @param opts.wasmFile - Custom URI for the Lua WebAssembly module. - * @param opts.env - Environment variables for the Lua engine. - * @param opts.stdin - Standard input for the Lua engine. - * @param opts.fs - File system that should be used for the Lua engine. - * @param opts.stdout - Standard output for the Lua engine. - * @param opts.stderr - Standard error for the Lua engine. - */ - public constructor(opts: Parameters[0] = {}) { - if (opts.wasmFile === undefined) { - const isBrowser = - (typeof window === 'object' && typeof window.document !== 'undefined') || - (typeof self === 'object' && self?.constructor?.name === 'DedicatedWorkerGlobalScope') - - if (isBrowser) { - opts.wasmFile = `https://unpkg.com/wasmoon@${version}/dist/glue.wasm` - } - } - - this.luaWasmPromise = LuaWasm.initialize(opts) - } - - /** - * Mounts a file in the Lua environment asynchronously. - * @param path - Path to the file in the Lua environment. - * @param content - Content of the file to be mounted. - * @returns - A Promise that resolves once the file is mounted. - */ - public async mountFile(path: string, content: string | ArrayBufferView): Promise { - this.mountFileSync(await this.getLuaModule(), path, content) - } - - /** - * Mounts a file in the Lua environment synchronously. - * @param luaWasm - Lua WebAssembly module. - * @param path - Path to the file in the Lua environment. - * @param content - Content of the file to be mounted. - */ - public mountFileSync(luaWasm: LuaWasm, path: string, content: string | ArrayBufferView): void { - const fileSep = path.lastIndexOf('/') - const file = path.substring(fileSep + 1) - const body = path.substring(0, path.length - file.length - 1) - - if (body.length > 0) { - const parts = body.split('/').reverse() - let parent = '' - - while (parts.length) { - const part = parts.pop() - if (!part) { - continue - } - - const current = `${parent}/${part}` - try { - luaWasm.module.FS.mkdir(current) - } catch { - // ignore EEXIST - } - - parent = current - } - } - - luaWasm.module.FS.writeFile(path, content) - } - - /** - * Creates a Lua engine with the specified options. - * @param [options] - Configuration options for the Lua engine. - * @returns - A Promise that resolves to a new LuaEngine instance. - */ - public async createEngine(options: CreateEngineOptions = {}): Promise { - return new LuaEngine(await this.getLuaModule(), options) - } - - /** - * Gets the Lua WebAssembly module. - * @returns - A Promise that resolves to the Lua WebAssembly module. - */ - public async getLuaModule(): Promise { - return this.luaWasmPromise - } -} diff --git a/src/global.ts b/src/global.ts index 0fbef7a..fd28272 100755 --- a/src/global.ts +++ b/src/global.ts @@ -1,4 +1,4 @@ -import type LuaWasm from './luawasm' +import type LuaModule from './module' import Thread from './thread' import LuaTypeExtension from './type-extension' import { LuaLibraries, LuaType } from './types' @@ -17,18 +17,18 @@ export default class Global extends Thread { /** * Constructs a new Global instance. - * @param cmodule - The Lua WebAssembly module. + * @param cmodule - The Lua module. * @param shouldTraceAllocations - Whether to trace memory allocations. */ - public constructor(cmodule: LuaWasm, shouldTraceAllocations: boolean) { + public constructor(cmodule: LuaModule, shouldTraceAllocations: boolean) { if (shouldTraceAllocations) { const memoryStats: LuaMemoryStats = { memoryUsed: 0 } - const allocatorFunctionPointer = cmodule.module.addFunction( + const allocatorFunctionPointer = cmodule._emscripten.addFunction( (_userData: number, pointer: number, oldSize: number, newSize: number): number => { if (newSize === 0) { if (pointer) { memoryStats.memoryUsed -= oldSize - cmodule.module._free(pointer) + cmodule._emscripten._free(pointer) } return 0 } @@ -40,7 +40,7 @@ export default class Global extends Thread { return 0 } - const reallocated = cmodule.module._realloc(pointer, newSize) + const reallocated = cmodule._emscripten._realloc(pointer, newSize) if (reallocated) { memoryStats.memoryUsed = endMemory } @@ -51,7 +51,7 @@ export default class Global extends Thread { const address = cmodule.lua_newstate(allocatorFunctionPointer, null) if (!address) { - cmodule.module.removeFunction(allocatorFunctionPointer) + cmodule._emscripten.removeFunction(allocatorFunctionPointer) throw new Error('lua_newstate returned a null pointer') } super(cmodule, [], address) @@ -84,7 +84,7 @@ export default class Global extends Thread { this.lua.lua_close(this.address) if (this.allocatorFunctionPointer) { - this.lua.module.removeFunction(this.allocatorFunctionPointer) + this.lua._emscripten.removeFunction(this.allocatorFunctionPointer) } for (const wrapper of this.typeExtensions) { diff --git a/src/index.ts b/src/index.ts index 243c251..11cb4a6 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export { default as LuaEngine } from './engine' -export { default as LuaFactory } from './factory' +export { default as Lua } from './lua' export { default as LuaGlobal } from './global' export { default as LuaMultiReturn } from './multireturn' export { default as LuaRawResult } from './raw-result' @@ -7,9 +7,12 @@ export { default as LuaThread } from './thread' // Export the underlying bindings to allow users to just // use the bindings rather than the wrappers. export { decorate, Decoration } from './decoration' -export { default as LuaWasm } from './luawasm' +export { default as LuaModule } from './module' export { default as LuaTypeExtension } from './type-extension' export { decorateFunction } from './type-extensions/function' export { decorateProxy } from './type-extensions/proxy' export { decorateUserdata } from './type-extensions/userdata' export * from './types' + +import Lua from './lua' +export default Lua diff --git a/src/lua.ts b/src/lua.ts new file mode 100755 index 0000000..ac799de --- /dev/null +++ b/src/lua.ts @@ -0,0 +1,46 @@ +import LuaModule from './module' +import LuaEngine from './engine' +import { CreateEngineOptions } from './types' + +/** + * Represents a factory for creating and configuring Lua engines. + */ +export default class Lua { + /** + * Constructs a new LuaFactory instance. + * @param opts.wasmFile - Custom URI for the Lua WebAssembly module. + * @param opts.env - Environment variables for the Lua engine. + * @param opts.stdin - Standard input for the Lua engine. + * @param opts.fs - File system that should be used for the Lua engine. + * @param opts.stdout - Standard output for the Lua engine. + * @param opts.stderr - Standard error for the Lua engine. + */ + public static async load(luaModuleOpts: Parameters[0] = {}): Promise { + return new Lua(await LuaModule.initialize(luaModuleOpts)) + } + + public constructor(public readonly module: LuaModule) {} + + public createState(stateOpts: CreateEngineOptions = {}): LuaEngine { + return new LuaEngine(this.module, stateOpts) + } + + /** + * Mounts a file in the Lua environment synchronously. + * @param path - Path to the file in the Lua environment. + * @param content - Content of the file to be mounted. + */ + public mountFile(path: string, content: string | ArrayBufferView): void { + const dirname = this.module._emscripten.PATH.dirname(path) + this.module._emscripten.FS.mkdirTree(dirname) + this.module._emscripten.FS.writeFile(path, content) + } + + public get filesystem(): typeof this.module._emscripten.FS { + return this.module._emscripten.FS + } + + public get path(): typeof this.module._emscripten.PATH { + return this.module._emscripten.PATH + } +} diff --git a/src/luawasm.ts b/src/module.ts similarity index 95% rename from src/luawasm.ts rename to src/module.ts index 789373d..1f0d8ad 100755 --- a/src/luawasm.ts +++ b/src/module.ts @@ -1,5 +1,7 @@ import initWasmModule from '../build/glue.js' -import { LUA_REGISTRYINDEX, LuaReturn, LuaState, LuaType } from './types' +import { LUA_REGISTRYINDEX, LuaReturn, LuaState, LuaType } from './types.js' +// A rolldown plugin will resolve this to the current version on package.json +import version from 'package-version' type EnvironmentVariables = Record @@ -9,7 +11,16 @@ interface LuaEmscriptenModule extends EmscriptenModule { removeFunction: typeof removeFunction setValue: typeof setValue getValue: typeof getValue - FS: typeof FS + FS: typeof FS & { + mkdirTree: (path: string) => void + filesystems: { + NODEFS: Emscripten.FileSystemType + MEMFS: Emscripten.FileSystemType + } + } + PATH: { + dirname: (typeof import('node:path'))['dirname'] + } stringToNewUTF8: typeof allocateUTF8 lengthBytesUTF8: typeof lengthBytesUTF8 stringToUTF8: typeof stringToUTF8 @@ -24,7 +35,7 @@ interface ReferenceMetadata { refCount: number } -export default class LuaWasm { +export default class LuaModule { public static async initialize(opts: { wasmFile?: string env?: EnvironmentVariables @@ -32,9 +43,17 @@ export default class LuaWasm { stdin?: () => string stdout?: (content: string) => void stderr?: (content: string) => void - }): Promise { - const fs = opts.fs === 'node' && typeof process !== 'undefined' ? await import('node:fs') : null - const child_process = opts.fs === 'node' && typeof process !== 'undefined' ? await import('node:child_process') : null + }): Promise { + const isBrowser = + (typeof window === 'object' && typeof window.document !== 'undefined') || + (typeof self === 'object' && self?.constructor?.name === 'DedicatedWorkerGlobalScope') + + if (opts.wasmFile === undefined && isBrowser) { + opts.wasmFile = `https://unpkg.com/wasmoon@${version}/dist/glue.wasm` + } + + const fs = !isBrowser && opts.fs === 'node' && typeof process !== 'undefined' ? await import('node:fs') : null + const child_process = !isBrowser && opts.fs === 'node' && typeof process !== 'undefined' ? await import('node:child_process') : null const module: LuaEmscriptenModule = await initWasmModule({ print: opts.stdout, @@ -75,7 +94,7 @@ export default class LuaWasm { for (const dir of rootdirs) { try { - const moduleFS = initializedModule.FS as any // emscripten FS module is not correctly typed + const moduleFS = initializedModule.FS moduleFS.mkdirTree(dir) moduleFS.mount(moduleFS.filesystems.NODEFS, { root: dir }, dir) } catch { @@ -117,10 +136,10 @@ export default class LuaWasm { } }, }) - return new LuaWasm(module) + return new LuaModule(module) } - public module: LuaEmscriptenModule + public _emscripten: LuaEmscriptenModule public luaL_checkversion_: (L: LuaState, ver: number, sz: number) => void public luaL_getmetafield: (L: LuaState, obj: number, e: string | null) => LuaType @@ -283,7 +302,7 @@ export default class LuaWasm { private lastRefIndex?: number public constructor(module: LuaEmscriptenModule) { - this.module = module + this._emscripten = module this.luaL_checkversion_ = this.cwrap('luaL_checkversion_', null, ['number', 'number', 'number']) this.luaL_getmetafield = this.cwrap('luaL_getmetafield', 'number', ['number', 'number', 'string']) @@ -521,7 +540,7 @@ export default class LuaWasm { const hasStringOrNumber = argTypes.some((argType) => argType === 'string|number') if (!hasStringOrNumber) { return (...args: any[]) => - this.module.ccall(name, returnType, argTypes as Emscripten.JSType[], args as Emscripten.TypeCompatibleWithC[]) + this._emscripten.ccall(name, returnType, argTypes as Emscripten.JSType[], args as Emscripten.TypeCompatibleWithC[]) } return (...args: any[]) => { @@ -533,7 +552,7 @@ export default class LuaWasm { } else { // because it will be freed later, this can only be used on functions that lua internally copies the string if (args[i]?.length > 1024) { - const bufferPointer = this.module.stringToNewUTF8(args[i] as string) + const bufferPointer = this._emscripten.stringToNewUTF8(args[i] as string) args[i] = bufferPointer pointersToBeFreed.push(bufferPointer) return 'number' @@ -546,10 +565,10 @@ export default class LuaWasm { }) try { - return this.module.ccall(name, returnType, resolvedArgTypes, args as Emscripten.TypeCompatibleWithC[]) + return this._emscripten.ccall(name, returnType, resolvedArgTypes, args as Emscripten.TypeCompatibleWithC[]) } finally { for (const pointer of pointersToBeFreed) { - this.module._free(pointer) + this._emscripten._free(pointer) } } } diff --git a/src/thread.ts b/src/thread.ts index f02ee49..bd9d832 100755 --- a/src/thread.ts +++ b/src/thread.ts @@ -1,5 +1,5 @@ import { Decoration } from './decoration' -import type LuaWasm from './luawasm' +import type LuaModule from './module' import MultiReturn from './multireturn' import { Pointer } from './pointer' import LuaTypeExtension from './type-extension' @@ -26,14 +26,14 @@ const INSTRUCTION_HOOK_COUNT = 1000 export default class Thread { public readonly address: LuaState - public readonly lua: LuaWasm + public readonly lua: LuaModule protected readonly typeExtensions: OrderedExtension[] private closed = false private hookFunctionPointer: number | undefined private timeout?: number private readonly parent?: Thread - public constructor(lua: LuaWasm, typeExtensions: OrderedExtension[], address: number, parent?: Thread) { + public constructor(lua: LuaModule, typeExtensions: OrderedExtension[], address: number, parent?: Thread) { this.lua = lua this.typeExtensions = typeExtensions this.address = address @@ -53,14 +53,14 @@ export default class Thread { } public loadString(luaCode: string, name?: string): void { - const size = this.lua.module.lengthBytesUTF8(luaCode) + const size = this.lua._emscripten.lengthBytesUTF8(luaCode) const pointerSize = size + 1 - const bufferPointer = this.lua.module._malloc(pointerSize) + const bufferPointer = this.lua._emscripten._malloc(pointerSize) try { - this.lua.module.stringToUTF8(luaCode, bufferPointer, pointerSize) + this.lua._emscripten.stringToUTF8(luaCode, bufferPointer, pointerSize) this.assertOk(this.lua.luaL_loadbufferx(this.address, bufferPointer, size, name ?? bufferPointer, null)) } finally { - this.lua.module._free(bufferPointer) + this.lua._emscripten._free(bufferPointer) } } @@ -69,16 +69,16 @@ export default class Thread { } public resume(argCount = 0): LuaResumeResult { - const dataPointer = this.lua.module._malloc(PointerSize) + const dataPointer = this.lua._emscripten._malloc(PointerSize) try { - this.lua.module.setValue(dataPointer, 0, 'i32') + this.lua._emscripten.setValue(dataPointer, 0, 'i32') const luaResult = this.lua.lua_resume(this.address, null, argCount, dataPointer) return { result: luaResult, - resultCount: this.lua.module.getValue(dataPointer, 'i32'), + resultCount: this.lua._emscripten.getValue(dataPointer, 'i32'), } } finally { - this.lua.module._free(dataPointer) + this.lua._emscripten._free(dataPointer) } } @@ -312,7 +312,7 @@ export default class Thread { } if (this.hookFunctionPointer) { - this.lua.module.removeFunction(this.hookFunctionPointer) + this.lua._emscripten.removeFunction(this.hookFunctionPointer) } this.closed = true @@ -322,7 +322,7 @@ export default class Thread { public setTimeout(timeout: number | undefined): void { if (timeout && timeout > 0) { if (!this.hookFunctionPointer) { - this.hookFunctionPointer = this.lua.module.addFunction((): void => { + this.hookFunctionPointer = this.lua._emscripten.addFunction((): void => { if (Date.now() > timeout) { this.pushValue(new LuaTimeoutError(`thread timeout exceeded`)) this.lua.lua_error(this.address) diff --git a/src/type-extension.ts b/src/type-extension.ts index 4543cd3..e4929cc 100644 --- a/src/type-extension.ts +++ b/src/type-extension.ts @@ -25,7 +25,7 @@ export default abstract class LuaTypeExtension { public constructor(thread: Global, injectObject: boolean) { super(thread, 'js_error') - this.gcPointer = thread.lua.module.addFunction((functionStateAddress: LuaState) => { + this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaState) => { // Throws a lua error which does a jump if it does not match. const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) - const referencePointer = thread.lua.module.getValue(userDataPointer, '*') + const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') thread.lua.unref(referencePointer) return LuaReturn.Ok @@ -72,7 +72,7 @@ class ErrorTypeExtension extends TypeExtension { } public close(): void { - this.thread.lua.module.removeFunction(this.gcPointer) + this.thread.lua._emscripten.removeFunction(this.gcPointer) } } diff --git a/src/type-extensions/function.ts b/src/type-extensions/function.ts index 1972dc3..28b5283 100644 --- a/src/type-extensions/function.ts +++ b/src/type-extensions/function.ts @@ -50,12 +50,12 @@ class FunctionTypeExtension extends TypeExtension { + this.gcPointer = thread.lua._emscripten.addFunction((calledL: LuaState) => { // Throws a lua error which does a jump if it does not match. thread.lua.luaL_checkudata(calledL, 1, this.name) const userDataPointer = thread.lua.luaL_checkudata(calledL, 1, this.name) - const referencePointer = thread.lua.module.getValue(userDataPointer, '*') + const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') thread.lua.unref(referencePointer) return LuaReturn.Ok @@ -74,11 +74,11 @@ class FunctionTypeExtension extends TypeExtension { + this.functionWrapper = thread.lua._emscripten.addFunction((calledL: LuaState) => { const calledThread = thread.stateToThread(calledL) const refUserdata = thread.lua.luaL_checkudata(calledL, thread.lua.lua_upvalueindex(1), this.name) - const refPointer = thread.lua.module.getValue(refUserdata, '*') + const refPointer = thread.lua._emscripten.getValue(refUserdata, '*') const { target, options } = thread.lua.getRef(refPointer) as Decoration const argsQuantity = calledThread.getTop() @@ -127,8 +127,8 @@ class FunctionTypeExtension extends TypeExtension { public constructor(thread: Global) { super(thread, 'js_null') - this.gcPointer = thread.lua.module.addFunction((functionStateAddress: LuaState) => { + this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaState) => { // Throws a lua error which does a jump if it does not match. const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) - const referencePointer = thread.lua.module.getValue(userDataPointer, '*') + const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') thread.lua.unref(referencePointer) return LuaReturn.Ok @@ -69,7 +69,7 @@ class NullTypeExtension extends TypeExtension { } public close(): void { - this.thread.lua.module.removeFunction(this.gcPointer) + this.thread.lua._emscripten.removeFunction(this.gcPointer) } } diff --git a/src/type-extensions/promise.ts b/src/type-extensions/promise.ts index 0aa2c51..ea8da1b 100644 --- a/src/type-extensions/promise.ts +++ b/src/type-extensions/promise.ts @@ -14,10 +14,10 @@ class PromiseTypeExtension extends TypeExtension> { public constructor(thread: Global, injectObject: boolean) { super(thread, 'js_promise') - this.gcPointer = thread.lua.module.addFunction((functionStateAddress: LuaState) => { + this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaState) => { // Throws a lua error which does a jump if it does not match. const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) - const referencePointer = thread.lua.module.getValue(userDataPointer, '*') + const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') thread.lua.unref(referencePointer) return LuaReturn.Ok @@ -63,7 +63,7 @@ class PromiseTypeExtension extends TypeExtension> { promiseResult = { status: 'rejected', value: err } }) - const continuance = this.thread.lua.module.addFunction((continuanceState: LuaState) => { + const continuance = this.thread.lua._emscripten.addFunction((continuanceState: LuaState) => { // If this yield has been called from within a coroutine and so manually resumed // then there may not yet be any results. In that case yield again. if (!promiseResult) { @@ -75,7 +75,7 @@ class PromiseTypeExtension extends TypeExtension> { return thread.lua.lua_yieldk(functionThread.address, 0, 0, continuance) } - this.thread.lua.module.removeFunction(continuance) + this.thread.lua._emscripten.removeFunction(continuance) const continuanceThread = thread.stateToThread(continuanceState) @@ -128,7 +128,7 @@ class PromiseTypeExtension extends TypeExtension> { } public close(): void { - this.thread.lua.module.removeFunction(this.gcPointer) + this.thread.lua._emscripten.removeFunction(this.gcPointer) } public pushValue(thread: Thread, decoration: Decoration>): boolean { diff --git a/src/type-extensions/proxy.ts b/src/type-extensions/proxy.ts index 9be9b1d..9d4d76b 100644 --- a/src/type-extensions/proxy.ts +++ b/src/type-extensions/proxy.ts @@ -22,10 +22,10 @@ class ProxyTypeExtension extends TypeExtension { public constructor(thread: Global) { super(thread, 'js_proxy') - this.gcPointer = thread.lua.module.addFunction((functionStateAddress: LuaState) => { + this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaState) => { // Throws a lua error which does a jump if it does not match. const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) - const referencePointer = thread.lua.module.getValue(userDataPointer, '*') + const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') thread.lua.unref(referencePointer) return LuaReturn.Ok @@ -131,7 +131,7 @@ class ProxyTypeExtension extends TypeExtension { public getValue(thread: Thread, index: number): any { const refUserdata = thread.lua.lua_touserdata(thread.address, index) - const referencePointer = thread.lua.module.getValue(refUserdata, '*') + const referencePointer = thread.lua._emscripten.getValue(refUserdata, '*') return thread.lua.getRef(referencePointer) } @@ -169,7 +169,7 @@ class ProxyTypeExtension extends TypeExtension { } public close(): void { - this.thread.lua.module.removeFunction(this.gcPointer) + this.thread.lua._emscripten.removeFunction(this.gcPointer) } } diff --git a/src/type-extensions/userdata.ts b/src/type-extensions/userdata.ts index f09ae7e..89a3811 100644 --- a/src/type-extensions/userdata.ts +++ b/src/type-extensions/userdata.ts @@ -18,10 +18,10 @@ class UserdataTypeExtension extends TypeExtension { + this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaState) => { // Throws a lua error which does a jump if it does not match. const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) - const referencePointer = thread.lua.module.getValue(userDataPointer, '*') + const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') thread.lua.unref(referencePointer) return LuaReturn.Ok @@ -49,7 +49,7 @@ class UserdataTypeExtension extends TypeExtension { +describe('State', () => { let intervals = [] const setIntervalSafe = (callback, interval) => { intervals.push(setInterval(() => callback(), interval)) @@ -33,32 +33,32 @@ describe('Engine', () => { }) it('receive lua table on JS function should succeed', async () => { - const engine = await getEngine() - engine.global.set('stringify', (table) => { + const state = await getState() + state.global.set('stringify', (table) => { return JSON.stringify(table) }) - await engine.doString('value = stringify({ test = 1 })') + await state.doString('value = stringify({ test = 1 })') - expect(engine.global.get('value')).to.be.equal(JSON.stringify({ test: 1 })) + expect(state.global.get('value')).to.be.equal(JSON.stringify({ test: 1 })) }) it('get a global table inside a JS function called by lua should succeed', async () => { - const engine = await getEngine() - engine.global.set('t', { test: 1 }) - engine.global.set('test', () => { - return engine.global.get('t') + const state = await getState() + state.global.set('t', { test: 1 }) + state.global.set('test', () => { + return state.global.get('t') }) - const value = await engine.doString('return test(2)') + const value = await state.doString('return test(2)') expect(value).to.be.eql({ test: 1 }) }) it('receive JS object on lua should succeed', async () => { - const engine = await getEngine() + const state = await getState() - engine.global.set('test', () => { + state.global.set('test', () => { return { aaaa: 1, bbb: 'hey', @@ -67,27 +67,27 @@ describe('Engine', () => { }, } }) - const value = await engine.doString('return test().test()') + const value = await state.doString('return test().test()') expect(value).to.be.equal(22) }) it('receive JS object with circular references on lua should succeed', async () => { - const engine = await getEngine() + const state = await getState() const obj = { hello: 'world', } obj.self = obj - engine.global.set('obj', obj) + state.global.set('obj', obj) - const value = await engine.doString('return obj.self.self.self.hello') + const value = await state.doString('return obj.self.self.self.hello') expect(value).to.be.equal('world') }) it('receive Lua object with circular references on JS should succeed', async () => { - const engine = await getEngine() - const value = await engine.doString(` + const state = await getState() + const value = await state.doString(` local obj1 = { hello = 'world', } @@ -122,8 +122,8 @@ describe('Engine', () => { }) it('receive lua array with circular references on JS should succeed', async () => { - const engine = await getEngine() - const value = await engine.doString(` + const state = await getState() + const value = await state.doString(` obj = { "hello", "world" @@ -138,7 +138,7 @@ describe('Engine', () => { }) it('receive JS object with multiple circular references on lua should succeed', async () => { - const engine = await getEngine() + const state = await getState() const obj1 = { hello: 'world', } @@ -147,45 +147,45 @@ describe('Engine', () => { hello: 'everybody', } obj2.self = obj2 - engine.global.set('obj', { obj1, obj2 }) + state.global.set('obj', { obj1, obj2 }) - await engine.doString(` + await state.doString(` assert(obj.obj1.self.self.hello == "world") assert(obj.obj2.self.self.hello == "everybody") `) }) it('receive JS object with null prototype on lua should succeed', async () => { - const engine = await getEngine() + const state = await getState() const obj = Object.create(null) obj.hello = 'world' - engine.global.set('obj', obj) + state.global.set('obj', obj) - const value = await engine.doString(`return obj.hello`) + const value = await state.doString(`return obj.hello`) expect(value).to.be.equal('world') }) it('a lua error should throw on JS', async () => { - const engine = await getEngine() + const state = await getState() - await expect(engine.doString(`x -`)).to.eventually.be.rejected + await expect(state.doString(`x -`)).to.eventually.be.rejected }) it('call a lua function from JS should succeed', async () => { - const engine = await getEngine() + const state = await getState() - await engine.doString(`function sum(x, y) return x + y end`) - const sum = engine.global.get('sum') + await state.doString(`function sum(x, y) return x + y end`) + const sum = state.global.get('sum') expect(sum(10, 50)).to.be.equal(60) }) it('scheduled lua calls should succeed', async () => { - const engine = await getEngine() - engine.global.set('setInterval', setIntervalSafe) + const state = await getState() + state.global.set('setInterval', setIntervalSafe) - await engine.doString(` + await state.doString(` test = "" setInterval(function() test = test .. "i" @@ -193,33 +193,33 @@ describe('Engine', () => { `) await setTimeout(20) - const test = engine.global.get('test') + const test = state.global.get('test') expect(test).length.above(3) expect(test).length.below(21) expect(test).to.be.equal(''.padEnd(test.length, 'i')) }) it('scheduled lua calls should fail silently if invalid', async () => { - const engine = await getEngine() - engine.global.set('setInterval', setIntervalSafe) + const state = await getState() + state.global.set('setInterval', setIntervalSafe) const originalConsoleWarn = console.warn console.warn = mock.fn() - await engine.doString(` + await state.doString(` test = 0 setInterval(function() test = test + 1 end, 5) `) - engine.global.close() + state.global.close() await setTimeout(5 + 5) console.warn = originalConsoleWarn }) it('call lua function from JS passing an array argument should succeed', async () => { - const engine = await getEngine() + const state = await getState() - const sum = await engine.doString(` + const sum = await state.doString(` return function(arr) local sum = 0 for k, v in ipairs(arr) do @@ -233,24 +233,24 @@ describe('Engine', () => { }) it('call a global function with multiple returns should succeed', async () => { - const engine = await getEngine() + const state = await getState() - await engine.doString(` + await state.doString(` function f(x,y) return 1,x,y,"Hello World",{},function() end end `) - const returns = engine.global.call('f', 10, 25) + const returns = state.global.call('f', 10, 25) expect(returns).to.have.length(6) expect(returns.slice(0, -1)).to.eql([1, 10, 25, 'Hello World', {}]) expect(returns.at(-1)).to.be.a('function') }) it('get a lua thread should succeed', async () => { - const engine = await getEngine() + const state = await getState() - const thread = await engine.doString(` + const thread = await state.doString(` return coroutine.create(function() print("hey") end) @@ -261,15 +261,15 @@ describe('Engine', () => { }) it('a JS error should pause lua execution', async () => { - const engine = await getEngine() + const state = await getState() const check = mock.fn() - engine.global.set('check', check) - engine.global.set('throw', () => { + state.global.set('check', check) + state.global.set('throw', () => { throw new Error('expected error') }) await expect( - engine.doString(` + state.doString(` throw() check() `), @@ -278,14 +278,14 @@ describe('Engine', () => { }) it('catch a JS error with pcall should succeed', async () => { - const engine = await getEngine() + const state = await getState() const check = mock.fn() - engine.global.set('check', check) - engine.global.set('throw', () => { + state.global.set('check', check) + state.global.set('throw', () => { throw new Error('expected error') }) - await engine.doString(` + await state.doString(` local success, err = pcall(throw) assert(success == false) assert(tostring(err) == "Error: expected error") @@ -296,11 +296,11 @@ describe('Engine', () => { }) it('call a JS function in a different thread should succeed', async () => { - const engine = await getEngine() + const state = await getState() const sum = mock.fn((x, y) => x + y) - engine.global.set('sum', sum) + state.global.set('sum', sum) - await engine.doString(` + await state.doString(` coroutine.resume(coroutine.create(function() sum(10, 20) end)) @@ -311,8 +311,8 @@ describe('Engine', () => { }) it('get callable table as function should succeed', async () => { - const engine = await getEngine() - await engine.doString(` + const state = await getState() + await state.doString(` _G['sum'] = setmetatable({}, { __call = function(self, x, y) return x + y @@ -320,15 +320,15 @@ describe('Engine', () => { }) `) - engine.global.lua.lua_getglobal(engine.global.address, 'sum') - const sum = engine.global.getValue(-1, LuaType.Function) + state.global.lua.lua_getglobal(state.global.address, 'sum') + const sum = state.global.getValue(-1, LuaType.Function) expect(sum(10, 30)).to.be.equal(40) }) it('lua_resume with yield succeeds', async () => { - const engine = await getEngine() - const thread = engine.global.newThread() + const state = await getState() + const thread = state.global.newThread() thread.loadString(` local yieldRes = coroutine.yield(10) return yieldRes @@ -353,34 +353,34 @@ describe('Engine', () => { }) it('get memory with allocation tracing should succeeds', async () => { - const engine = await getEngine({ traceAllocations: true }) - expect(engine.global.getMemoryUsed()).to.be.greaterThan(0) + const state = await getState({ traceAllocations: true }) + expect(state.global.getMemoryUsed()).to.be.greaterThan(0) }) it('get memory should return correct', async () => { - const engine = await getEngine({ traceAllocations: true }) + const state = await getState({ traceAllocations: true }) - const totalMemory = await engine.doString(` + const totalMemory = await state.doString(` collectgarbage() local x = 10 local batata = { dawdwa = 1 } return collectgarbage('count') * 1024 `) - expect(engine.global.getMemoryUsed()).to.be.equal(totalMemory) + expect(state.global.getMemoryUsed()).to.be.equal(totalMemory) }) it('get memory without tracing should throw', async () => { - const engine = await getEngine({ traceAllocations: false }) + const state = await getState({ traceAllocations: false }) - expect(() => engine.global.getMemoryUsed()).to.throw() + expect(() => state.global.getMemoryUsed()).to.throw() }) it('limit memory use causes program loading failure succeeds', async () => { - const engine = await getEngine({ traceAllocations: true }) - engine.global.setMemoryMax(engine.global.getMemoryUsed()) + const state = await getState({ traceAllocations: true }) + state.global.setMemoryMax(state.global.getMemoryUsed()) expect(() => { - engine.global.loadString(` + state.global.loadString(` local a = 10 local b = 20 return a + b @@ -388,8 +388,8 @@ describe('Engine', () => { }).to.throw('not enough memory') // Remove the limit and retry - engine.global.setMemoryMax(undefined) - engine.global.loadString(` + state.global.setMemoryMax(undefined) + state.global.loadString(` local a = 10 local b = 20 return a + b @@ -397,35 +397,35 @@ describe('Engine', () => { }) it('limit memory use causes program runtime failure succeeds', async () => { - const engine = await getEngine({ traceAllocations: true }) - engine.global.loadString(` + const state = await getState({ traceAllocations: true }) + state.global.loadString(` local tab = {} for i = 1, 50, 1 do tab[i] = i end `) - engine.global.setMemoryMax(engine.global.getMemoryUsed()) + state.global.setMemoryMax(state.global.getMemoryUsed()) - await expect(engine.global.run()).to.eventually.be.rejectedWith('not enough memory') + await expect(state.global.run()).to.eventually.be.rejectedWith('not enough memory') }) it('table supported circular dependencies', async () => { - const engine = await getEngine() + const state = await getState() const a = { name: 'a' } const b = { name: 'b' } b.a = a a.b = b - engine.global.pushValue(a) - const res = engine.global.getValue(-1) + state.global.pushValue(a) + const res = state.global.getValue(-1) expect(res.b.a).to.be.eql(res) }) it('wrap a js object (with metatable)', async () => { - const engine = await getEngine() - engine.global.set('TestClass', { + const state = await getState() + state.global.set('TestClass', { create: (name) => { return decorate( { @@ -446,7 +446,7 @@ describe('Engine', () => { }, }) - const res = await engine.doString(` + const res = await state.doString(` local instance = TestClass.create("demo name") return instance.name `) @@ -454,11 +454,11 @@ describe('Engine', () => { }) it('wrap a js object using proxy', async () => { - const engine = await getEngine() - engine.global.set('TestClass', { + const state = await getState() + state.global.set('TestClass', { create: (name) => new TestClass(name), }) - const res = await engine.doString(` + const res = await state.doString(` local instance = TestClass.create("demo name 2") return instance:getName() `) @@ -466,11 +466,11 @@ describe('Engine', () => { }) it('wrap a js object using proxy and apply metatable in lua', async () => { - const engine = await getEngine() - engine.global.set('TestClass', { + const state = await getState() + state.global.set('TestClass', { create: (name) => new TestClass(name), }) - const res = await engine.doString(` + const res = await state.doString(` local instance = TestClass.create("demo name 2") -- Based in the simple lua classes tutotial @@ -495,10 +495,10 @@ describe('Engine', () => { }) it('classes should be a userdata when proxied', async () => { - const engine = await getEngine() - engine.global.set('obj', { TestClass }) + const state = await getState() + state.global.set('obj', { TestClass }) - const testClass = await engine.doString(` + const testClass = await state.doString(` return obj.TestClass `) @@ -506,27 +506,27 @@ describe('Engine', () => { }) it('timeout blocking lua program', async () => { - const engine = await getEngine() - engine.global.loadString(` + const state = await getState() + state.global.loadString(` local i = 0 while true do i = i + 1 end `) - await expect(engine.global.run(0, { timeout: 5 })).eventually.to.be.rejectedWith('thread timeout exceeded') + await expect(state.global.run(0, { timeout: 5 })).eventually.to.be.rejectedWith('thread timeout exceeded') }) it('overwrite lib function', async () => { - const engine = await getEngine() + const state = await getState() let output = '' - engine.global.getTable(LuaLibraries.Base, (index) => { - engine.global.setField(index, 'print', (val) => { + state.global.getTable(LuaLibraries.Base, (index) => { + state.global.setField(index, 'print', (val) => { // Not a proper print implementation. output += `${val}\n` }) }) - await engine.doString(` + await state.doString(` print("hello") print("world") `) @@ -535,28 +535,28 @@ describe('Engine', () => { }) it('inject a userdata with a metatable should succeed', async () => { - const engine = await getEngine() + const state = await getState() const obj = decorate( {}, { metatable: { __index: (_, k) => `Hello ${k}!` }, }, ) - engine.global.set('obj', obj) + state.global.set('obj', obj) - const res = await engine.doString('return obj.World') + const res = await state.doString('return obj.World') expect(res).to.be.equal('Hello World!') }) it('a userdata should be collected', async () => { - const engine = await getEngine() + const state = await getState() const obj = {} - engine.global.set('obj', obj) - const refIndex = engine.global.lua.getLastRefIndex() - const oldRef = engine.global.lua.getRef(refIndex) + state.global.set('obj', obj) + const refIndex = state.global.lua.getLastRefIndex() + const oldRef = state.global.lua.getRef(refIndex) - await engine.doString(` + await state.doString(` local weaktable = {} setmetatable(weaktable, { __mode = "v" }) table.insert(weaktable, obj) @@ -566,43 +566,43 @@ describe('Engine', () => { `) expect(oldRef).to.be.equal(obj) - const newRef = engine.global.lua.getRef(refIndex) + const newRef = state.global.lua.getRef(refIndex) expect(newRef).to.be.equal(undefined) }) it('environment variables should be set', async () => { - const factory = getFactory({ TEST: 'true' }) - const engine = await factory.createEngine() + const lua = await getLua({ TEST: 'true' }) + const state = lua.createState() - const testEnvVar = await engine.doString(`return os.getenv('TEST')`) + const testEnvVar = await state.doString(`return os.getenv('TEST')`) expect(testEnvVar).to.be.equal('true') }) it('static methods should be callable on classes', async () => { - const engine = await getEngine() - engine.global.set('TestClass', TestClass) + const state = await getState() + state.global.set('TestClass', TestClass) - const testHello = await engine.doString(`return TestClass.hello()`) + const testHello = await state.doString(`return TestClass.hello()`) expect(testHello).to.be.equal('world') }) it('should be possible to access function properties', async () => { - const engine = await getEngine() + const state = await getState() const testFunction = () => undefined testFunction.hello = 'world' - engine.global.set('TestFunction', decorateProxy(testFunction, { proxy: true })) + state.global.set('TestFunction', decorateProxy(testFunction, { proxy: true })) - const testHello = await engine.doString(`return TestFunction.hello`) + const testHello = await state.doString(`return TestFunction.hello`) expect(testHello).to.be.equal('world') }) it('throw error includes stack trace', async () => { - const engine = await getEngine() + const state = await getState() try { - await engine.doString(` + await state.doString(` local function a() error("function a threw error") end @@ -622,12 +622,12 @@ describe('Engine', () => { }) it('should get only the last result on run', async () => { - const engine = await getEngine() + const state = await getState() - const a = await engine.doString(`return 1`) - const b = await engine.doString(`return 3`) - const c = engine.doStringSync(`return 2`) - const d = engine.doStringSync(`return 5`) + const a = await state.doString(`return 1`) + const b = await state.doString(`return 3`) + const c = state.doStringSync(`return 2`) + const d = state.doStringSync(`return 5`) expect(a).to.be.equal(1) expect(b).to.be.equal(3) @@ -636,12 +636,12 @@ describe('Engine', () => { }) it('should get only the return values on call function', async () => { - const engine = await getEngine() - engine.global.set('hello', (name) => `Hello ${name}!`) + const state = await getState() + state.global.set('hello', (name) => `Hello ${name}!`) - const a = await engine.doString(`return 1`) - const b = engine.doStringSync(`return 5`) - const values = engine.global.call('hello', 'joao') + const a = await state.doString(`return 1`) + const b = state.doStringSync(`return 5`) + const values = state.global.call('hello', 'joao') expect(a).to.be.equal(1) expect(b).to.be.equal(5) @@ -650,69 +650,69 @@ describe('Engine', () => { }) it('create a large string variable should succeed', async () => { - const engine = await getEngine() + const state = await getState() const str = 'a'.repeat(1000000) - engine.global.set('str', str) + state.global.set('str', str) - const res = await engine.doString('return str') + const res = await state.doString('return str') expect(res).to.be.equal(str) }) it('execute a large string should succeed', async () => { - const engine = await getEngine() + const state = await getState() const str = 'a'.repeat(1000000) - const res = await engine.doString(`return [[${str}]]`) + const res = await state.doString(`return [[${str}]]`) expect(res).to.be.equal(str) }) it('negative integers should be pushed and retrieved as string', async () => { - const engine = await getEngine() - engine.global.set('value', -1) + const state = await getState() + state.global.set('value', -1) - const res = await engine.doString(`return tostring(value)`) + const res = await state.doString(`return tostring(value)`) expect(res).to.be.equal('-1') }) it('negative integers should be pushed and retrieved as number', async () => { - const engine = await getEngine() - engine.global.set('value', -1) + const state = await getState() + state.global.set('value', -1) - const res = await engine.doString(`return value`) + const res = await state.doString(`return value`) expect(res).to.be.equal(-1) }) it('number greater than 32 bit int should be pushed and retrieved as string', async () => { - const engine = await getEngine() + const state = await getState() const value = 1689031554550 - engine.global.set('value', value) + state.global.set('value', value) - const res = await engine.doString(`return tostring(value)`) + const res = await state.doString(`return tostring(value)`) expect(res).to.be.equal(`${String(value)}`) }) it('number greater than 32 bit int should be pushed and retrieved as number', async () => { - const engine = await getEngine() + const state = await getState() const value = 1689031554550 - engine.global.set('value', value) + state.global.set('value', value) - const res = await engine.doString(`return value`) + const res = await state.doString(`return value`) expect(res).to.be.equal(value) }) it('number greater than 32 bit int should be usable as a format argument', async () => { - const engine = await getEngine() + const state = await getState() const value = 1689031554550 - engine.global.set('value', value) + state.global.set('value', value) - const res = await engine.doString(`return ("%d"):format(value)`) + const res = await state.doString(`return ("%d"):format(value)`) expect(res).to.be.equal('1689031554550') }) @@ -721,10 +721,10 @@ describe('Engine', () => { // When yielding within a callback the error 'attempt to yield across a C-call boundary'. // This test just checks that throwing that error still allows the lua global to be // re-used and doesn't cause JS to abort or some nonsense. - const engine = await getEngine() + const state = await getState() const testEmitter = new EventEmitter() - engine.global.set('yield', () => new Promise((resolve) => testEmitter.once('resolve', resolve))) - const resPromise = engine.doString(` + state.global.set('yield', () => new Promise((resolve) => testEmitter.once('resolve', resolve))) + const resPromise = state.doString(` local res = yield():next(function () coroutine.yield() return 15 @@ -735,13 +735,13 @@ describe('Engine', () => { testEmitter.emit('resolve') await expect(resPromise).to.eventually.be.rejectedWith('Error: attempt to yield across a C-call boundary') - expect(await engine.doString(`return 42`)).to.equal(42) + expect(await state.doString(`return 42`)).to.equal(42) }) it('forced yield within JS callback from Lua doesnt cause vm to crash', async () => { - const engine = await getEngine({ functionTimeout: 10 }) - engine.global.set('promise', Promise.resolve()) - const thread = engine.global.newThread() + const state = await getState({ functionTimeout: 10 }) + state.global.set('promise', Promise.resolve()) + const thread = state.global.newThread() thread.loadString(` promise:next(function () while true do @@ -751,13 +751,13 @@ describe('Engine', () => { `) await expect(thread.run(0, { timeout: 5 })).to.eventually.be.rejectedWith('thread timeout exceeded') - expect(await engine.doString(`return 42`)).to.equal(42) + expect(await state.doString(`return 42`)).to.equal(42) }) it('function callback timeout still allows timeout of caller thread', async () => { - const engine = await getEngine() - engine.global.set('promise', Promise.resolve()) - const thread = engine.global.newThread() + const state = await getState() + state.global.set('promise', Promise.resolve()) + const thread = state.global.newThread() thread.loadString(` promise:next(function () -- nothing @@ -768,33 +768,33 @@ describe('Engine', () => { }) it('null injected and valid', async () => { - const engine = await getEngine() - engine.global.loadString(` + const state = await getState() + state.global.loadString(` local args = { ... } assert(args[1] == null, string.format("expected first argument to be null, got %s", tostring(args[1]))) return null, args[1], tostring(null) `) - engine.global.pushValue(null) - const res = await engine.global.run(1) + state.global.pushValue(null) + const res = await state.global.run(1) expect(res).to.deep.equal([null, null, 'null']) }) it('null injected as nil', async () => { - const engine = await getEngine({ injectObjects: false }) - engine.global.loadString(` + const state = await getState({ injectObjects: false }) + state.global.loadString(` local args = { ... } assert(type(args[1]) == "nil", string.format("expected first argument to be nil, got %s", type(args[1]))) return nil, args[1], tostring(nil) `) - engine.global.pushValue(null) - const res = await engine.global.run(1) + state.global.pushValue(null) + const res = await state.global.run(1) expect(res).to.deep.equal([null, null, 'nil']) }) it('Nested callback from JS to Lua', async () => { - const engine = await getEngine() - engine.global.set('call', (fn) => fn()) - const res = await engine.doString(` + const state = await getState() + state.global.set('call', (fn) => fn()) + const res = await state.doString(` return call(function () return call(function () return 10 @@ -805,13 +805,13 @@ describe('Engine', () => { }) it('lots of doString calls should succeed', async () => { - const engine = await getEngine() + const state = await getState() const length = 10000 for (let i = 0; i < length; i++) { const a = Math.floor(Math.random() * 100) const b = Math.floor(Math.random() * 100) - const result = await engine.doString(`return ${a} + ${b};`) + const result = await state.doString(`return ${a} + ${b};`) expect(result).to.equal(a + b) } }) diff --git a/test/filesystem.test.js b/test/filesystem.test.js index 849f933..d1c5f54 100644 --- a/test/filesystem.test.js +++ b/test/filesystem.test.js @@ -1,66 +1,66 @@ import { expect } from 'chai' -import { getEngine, getFactory } from './utils.js' +import { getState, getLua } from './utils.js' describe('Filesystem', () => { it('mount a file and require inside lua should succeed', async () => { - const factory = getFactory() - await factory.mountFile('test.lua', 'answerToLifeTheUniverseAndEverything = 42') - const engine = await factory.createEngine() + const lua = await getLua() + lua.mountFile('test.lua', 'answerToLifeTheUniverseAndEverything = 42') + const state = lua.createState() - await engine.doString('require("test")') + await state.doString('require("test")') - expect(engine.global.get('answerToLifeTheUniverseAndEverything')).to.be.equal(42) + expect(state.global.get('answerToLifeTheUniverseAndEverything')).to.be.equal(42) }) it('mount a file in a complex directory and require inside lua should succeed', async () => { - const factory = getFactory() - await factory.mountFile('yolo/sofancy/test.lua', 'return 42') - const engine = await factory.createEngine() + const lua = await getLua() + lua.mountFile('yolo/sofancy/test.lua', 'return 42') + const state = lua.createState() - const value = await engine.doString('return require("yolo/sofancy/test")') + const value = await state.doString('return require("yolo/sofancy/test")') expect(value).to.be.equal(42) }) it('mount a init file and require the module inside lua should succeed', async () => { - const factory = getFactory() - await factory.mountFile('hello/init.lua', 'return 42') - const engine = await factory.createEngine() + const lua = await getLua() + lua.mountFile('hello/init.lua', 'return 42') + const state = lua.createState() - const value = await engine.doString('return require("hello")') + const value = await state.doString('return require("hello")') expect(value).to.be.equal(42) }) it('require a file which is not mounted should throw', async () => { - const engine = await getEngine() + const state = await getState() - await expect(engine.doString('require("nothing")')).to.eventually.be.rejected + await expect(state.doString('require("nothing")')).to.eventually.be.rejected }) it('mount a file and run it should succeed', async () => { - const factory = getFactory() - const engine = await factory.createEngine() + const lua = await getLua() + const state = lua.createState() - await factory.mountFile('init.lua', `return 42`) - const value = await engine.doFile('init.lua') + lua.mountFile('init.lua', `return 42`) + const value = await state.doFile('init.lua') expect(value).to.be.equal(42) }) it('run a file which is not mounted should throw', async () => { - const engine = await getEngine() + const state = await getState() - await expect(engine.doFile('init.lua')).to.eventually.be.rejected + await expect(state.doFile('init.lua')).to.eventually.be.rejected }) it('mount a file with a large content should succeed', async () => { - const factory = getFactory() - const engine = await factory.createEngine() + const lua = await getLua() + const state = lua.createState() const content = 'a'.repeat(1000000) - await factory.mountFile('init.lua', `local a = "${content}" return a`) - const value = await engine.doFile('init.lua') + lua.mountFile('init.lua', `local a = "${content}" return a`) + const value = await state.doFile('init.lua') expect(value).to.be.equal(content) }) diff --git a/test/initialization.test.js b/test/initialization.test.js index c0a4026..90952b4 100644 --- a/test/initialization.test.js +++ b/test/initialization.test.js @@ -1,13 +1,23 @@ -import { LuaFactory } from '../dist/index.js' +import { Lua } from '../dist/index.js' import { expect } from 'chai' describe('Initialization', () => { - it('create engine should succeed', async () => { - await new LuaFactory().createEngine() + it('create state should succeed', async () => { + const lua = await Lua.load() + lua.createState() }) - it('create engine with options should succeed', async () => { - await new LuaFactory().createEngine({ + it('create multiple states should succeed', async () => { + const lua = await Lua.load() + const state1 = lua.createState() + const state2 = lua.createState() + + expect(state1.global.address).to.not.be.equal(state2.global.address) + }) + + it('create state with options should succeed', async () => { + const lua = await Lua.load() + lua.createState({ enableProxy: true, injectObjects: true, openStandardLibs: true, @@ -19,9 +29,10 @@ describe('Initialization', () => { const env = { ENV_TEST: 'test', } - const engine = await new LuaFactory({ env }).createEngine() + const lua = await Lua.load({ env }) + const state = lua.createState() - const value = await engine.doString('return os.getenv("ENV_TEST")') + const value = await state.doString('return os.getenv("ENV_TEST")') expect(value).to.be.equal(env.ENV_TEST) }) diff --git a/test/luatests.js b/test/luatests.js index 681266e..cfe2df7 100644 --- a/test/luatests.js +++ b/test/luatests.js @@ -1,24 +1,23 @@ -import { LuaFactory } from '../dist/index.js' +import { Lua } from '../dist/index.js' import { fileURLToPath } from 'node:url' import { readFile, glob } from 'node:fs/promises' -const factory = new LuaFactory() +const lua = await Lua.load() const testsPath = import.meta.resolve('../lua/testes') const filePath = fileURLToPath(typeof testsPath === 'string' ? testsPath : await Promise.resolve(testsPath)) for await (const file of glob(`${filePath}/**/*.lua`)) { const relativeFile = file.replace(`${filePath}/`, '') - await factory.mountFile(relativeFile, await readFile(file)) + lua.mountFile(relativeFile, await readFile(file)) } -const lua = await factory.createEngine() -const luamodule = await factory.getLuaModule() -luamodule.lua_warning(lua.global.address, '@on', 0) -lua.global.set('arg', ['lua', 'all.lua']) -lua.global.set('_port', true) -lua.global.getTable('os', (i) => { - lua.global.setField(i, 'setlocale', (locale) => { +const state = lua.createState() +lua.module.lua_warning(state.global.address, '@on', 0) +state.global.set('arg', ['lua', 'all.lua']) +state.global.set('_port', true) +state.global.getTable('os', (i) => { + state.global.setField(i, 'setlocale', (locale) => { return locale && locale !== 'C' ? false : 'C' }) }) -lua.doFileSync('all.lua') +state.doFileSync('all.lua') diff --git a/test/promises.test.js b/test/promises.test.js index d7b1d60..6227707 100644 --- a/test/promises.test.js +++ b/test/promises.test.js @@ -1,16 +1,16 @@ import { expect } from 'chai' -import { getEngine, tick } from './utils.js' +import { getState, tick } from './utils.js' import { mock } from 'node:test' describe('Promises', () => { it('use promise next should succeed', async () => { - const engine = await getEngine() + const state = await getState() const check = mock.fn() - engine.global.set('check', check) + state.global.set('check', check) const promise = new Promise((resolve) => setTimeout(() => resolve(60), 5)) - engine.global.set('promise', promise) + state.global.set('promise', promise) - const res = engine.doString(` + const res = state.doString(` promise:next(check) `) @@ -21,13 +21,13 @@ describe('Promises', () => { }) it('chain promises with next should succeed', async () => { - const engine = await getEngine() + const state = await getState() const check = mock.fn() - engine.global.set('check', check) + state.global.set('check', check) const promise = new Promise((resolve) => resolve(60)) - engine.global.set('promise', promise) + state.global.set('promise', promise) - const res = engine.doString(` + const res = state.doString(` promise:next(function(value) return value * 2 end):next(check):next(check) @@ -42,12 +42,12 @@ describe('Promises', () => { }) it('call an async function should succeed', async () => { - const engine = await getEngine() - engine.global.set('asyncFunction', async () => Promise.resolve(60)) + const state = await getState() + state.global.set('asyncFunction', async () => Promise.resolve(60)) const check = mock.fn() - engine.global.set('check', check) + state.global.set('check', check) - const res = engine.doString(` + const res = state.doString(` asyncFunction():next(check) `) @@ -57,10 +57,10 @@ describe('Promises', () => { }) it('return an async function should succeed', async () => { - const engine = await getEngine() - engine.global.set('asyncFunction', async () => Promise.resolve(60)) + const state = await getState() + state.global.set('asyncFunction', async () => Promise.resolve(60)) - const asyncFunction = await engine.doString(` + const asyncFunction = await state.doString(` return asyncFunction `) const value = await asyncFunction() @@ -69,10 +69,10 @@ describe('Promises', () => { }) it('return a chained promise should succeed', async () => { - const engine = await getEngine() - engine.global.set('asyncFunction', async () => Promise.resolve(60)) + const state = await getState() + state.global.set('asyncFunction', async () => Promise.resolve(60)) - const asyncFunction = await engine.doString(` + const asyncFunction = await state.doString(` return asyncFunction():next(function(x) return x * 2 end) `) const value = await asyncFunction @@ -81,13 +81,13 @@ describe('Promises', () => { }) it('await an promise inside coroutine should succeed', async () => { - const engine = await getEngine() + const state = await getState() const check = mock.fn() - engine.global.set('check', check) + state.global.set('check', check) const promise = new Promise((resolve) => setTimeout(() => resolve(60), 5)) - engine.global.set('promise', promise) + state.global.set('promise', promise) - const res = engine.doString(` + const res = state.doString(` local co = coroutine.create(function() local value = promise:await() check(value) @@ -108,13 +108,13 @@ describe('Promises', () => { }) it('awaited coroutines should ignore resume until it resolves the promise', async () => { - const engine = await getEngine() + const state = await getState() const check = mock.fn() - engine.global.set('check', check) + state.global.set('check', check) const promise = new Promise((resolve) => setTimeout(() => resolve(60), 5)) - engine.global.set('promise', promise) + state.global.set('promise', promise) - const res = engine.doString(` + const res = state.doString(` local co = coroutine.create(function() local value = promise:await() check(value) @@ -133,9 +133,9 @@ describe('Promises', () => { }) it('await a thread run with async calls should succeed', async () => { - const engine = await getEngine() - engine.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) - const asyncThread = engine.global.newThread() + const state = await getState() + state.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) + const asyncThread = state.global.newThread() asyncThread.loadString(` sleep(1):await() @@ -147,9 +147,9 @@ describe('Promises', () => { }) it('run thread with async calls and yields should succeed', async () => { - const engine = await getEngine() - engine.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) - const asyncThread = engine.global.newThread() + const state = await getState() + state.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) + const asyncThread = state.global.newThread() asyncThread.loadString(` coroutine.yield() @@ -165,9 +165,9 @@ describe('Promises', () => { }) it('reject a promise should succeed', async () => { - const engine = await getEngine() - engine.global.set('throw', () => new Promise((_, reject) => reject(new Error('expected test error')))) - const asyncThread = engine.global.newThread() + const state = await getState() + state.global.set('throw', () => new Promise((_, reject) => reject(new Error('expected test error')))) + const asyncThread = state.global.newThread() asyncThread.loadString(` throw():await() @@ -178,9 +178,9 @@ describe('Promises', () => { }) it('pcall a promise await should succeed', async () => { - const engine = await getEngine() - engine.global.set('throw', () => new Promise((_, reject) => reject(new Error('expected test error')))) - const asyncThread = engine.global.newThread() + const state = await getState() + state.global.set('throw', () => new Promise((_, reject) => reject(new Error('expected test error')))) + const asyncThread = state.global.newThread() asyncThread.loadString(` local succeed, err = pcall(function() throw():await() end) @@ -192,13 +192,13 @@ describe('Promises', () => { }) it('catch a promise rejection should succeed', async () => { - const engine = await getEngine() + const state = await getState() const fulfilled = mock.fn() const rejected = mock.fn() - engine.global.set('handlers', { fulfilled, rejected }) - engine.global.set('throw', new Promise((_, reject) => reject(new Error('expected test error')))) + state.global.set('handlers', { fulfilled, rejected }) + state.global.set('throw', new Promise((_, reject) => reject(new Error('expected test error')))) - const res = engine.doString(` + const res = state.doString(` throw:next(handlers.fulfilled, handlers.rejected):catch(function() end) `) @@ -209,10 +209,10 @@ describe('Promises', () => { }) it('run with async callback', async () => { - const engine = await getEngine() - const thread = engine.global.newThread() + const state = await getState() + const thread = state.global.newThread() - engine.global.set('asyncCallback', async (input) => { + state.global.set('asyncCallback', async (input) => { return Promise.resolve(input * 2) }) @@ -232,8 +232,8 @@ describe('Promises', () => { }) it('promise creation from js', async () => { - const engine = await getEngine() - const res = await engine.doString(` + const state = await getState() + const res = await state.doString(` local promise = Promise.create(function (resolve) resolve(10) end) @@ -248,8 +248,8 @@ describe('Promises', () => { }) it('reject promise creation from js', async () => { - const engine = await getEngine() - const res = await engine.doString(` + const state = await getState() + const res = await state.doString(` local rejection = Promise.create(function (resolve, reject) reject("expected rejection") end) @@ -261,9 +261,9 @@ describe('Promises', () => { }) it('resolve multiple promises with promise.all', async () => { - const engine = await getEngine() - engine.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) - const resPromise = engine.doString(` + const state = await getState() + state.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) + const resPromise = state.doString(` local promises = {} for i = 1, 10 do table.insert(promises, sleep(5):next(function () @@ -278,9 +278,9 @@ describe('Promises', () => { }) it('error in promise next catchable', async () => { - const engine = await getEngine() - engine.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) - const resPromise = engine.doString(` + const state = await getState() + state.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) + const resPromise = state.doString(` return sleep(1):next(function () error("sleep done") end):await() @@ -289,11 +289,11 @@ describe('Promises', () => { }) it('should not be possible to await in synchronous run', async () => { - const engine = await getEngine() - engine.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) + const state = await getState() + state.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) expect(() => { - engine.doStringSync(`sleep(5):await()`) + state.doStringSync(`sleep(5):await()`) }).to.throw('cannot await in the main thread') }) }) diff --git a/test/utils.js b/test/utils.js index 3106620..8a0e97d 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,11 +1,12 @@ -import { LuaFactory } from '../dist/index.js' +import { Lua } from '../dist/index.js' -export const getFactory = (env) => { - return new LuaFactory({ env }) +export const getLua = (env) => { + return Lua.load({ env }) } -export const getEngine = (config = {}) => { - return new LuaFactory().createEngine({ +export const getState = async (config = {}) => { + const lua = await Lua.load() + return lua.createState({ injectObjects: true, ...config, }) diff --git a/utils/build-wasm.sh b/utils/build-wasm.sh index 6b18ceb..4818b30 100755 --- a/utils/build-wasm.sh +++ b/utils/build-wasm.sh @@ -20,6 +20,7 @@ emcc \ 'addFunction', \ 'removeFunction', \ 'FS', \ + 'PATH', \ 'ENV', \ 'getValue', \ 'setValue', \ From 9dac5f9e00fd70669215831d7b3340cca71ec775 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Fri, 13 Mar 2026 20:39:51 -0300 Subject: [PATCH 28/52] update packages, use oxc --- .prettierrc => .oxfmtrc.json | 4 +- .oxlintrc.json | 13 + bench/steps.js | 4 +- eslint.config.js | 140 -- lua | 2 +- package-lock.json | 2469 ++++++++++++------------------- package.json | 30 +- src/module.ts | 75 +- src/type-extensions/function.ts | 10 +- src/type-extensions/promise.ts | 1 + src/type-extensions/table.ts | 2 +- tsconfig.json | 2 +- utils/build-wasm.js | 3 +- utils/build-wasm.sh | 7 +- 14 files changed, 1067 insertions(+), 1695 deletions(-) rename .prettierrc => .oxfmtrc.json (61%) create mode 100644 .oxlintrc.json delete mode 100644 eslint.config.js diff --git a/.prettierrc b/.oxfmtrc.json similarity index 61% rename from .prettierrc rename to .oxfmtrc.json index f2060e4..600545a 100644 --- a/.prettierrc +++ b/.oxfmtrc.json @@ -5,5 +5,7 @@ "quoteProps": "consistent", "semi": false, "printWidth": 140, - "tabWidth": 4 + "tabWidth": 4, + "sortPackageJson": false, + "ignorePatterns": ["rolldown.config.*.js"] } diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..ceebf07 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,13 @@ +{ + "plugins": ["import", "promise", "node"], + "rules": { + "no-shadow": "off", + "no-extend-native": "off", + "consistent-function-scoping": "off", + "promise/always-return": "off" + }, + "ignorePatterns": ["dist/**", "build/**", "rolldown.config.ts", "rolldown.config.*.js", "utils/**"], + "env": { + "builtin": true + } +} diff --git a/bench/steps.js b/bench/steps.js index 9066fcd..ec6c273 100644 --- a/bench/steps.js +++ b/bench/steps.js @@ -47,8 +47,8 @@ const runInteropedHeapsort = async () => { const state = lua.createState() console.time('Run interoped heapsort') - const runHeapsort = await state.doString(heapsort) - assert(runHeapsort() === 10) + const executeHeapsort = await state.doString(heapsort) + assert(executeHeapsort() === 10) console.timeEnd('Run interoped heapsort') } diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 23e7807..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,140 +0,0 @@ -import eslint from '@eslint/js' -import tsParser from '@typescript-eslint/parser' -import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended' -import simpleImportSort from 'eslint-plugin-simple-import-sort' -import tseslint from 'typescript-eslint' - -export default [ - { - ignores: ['**/dist/*', '**/build/*', '**/rolldown.config.ts', '**/utils/*', 'eslint.config.js'], - }, - eslint.configs.recommended, - ...tseslint.configs.recommended, - eslintPluginPrettierRecommended, - { - files: ['test/**/*.js', 'bench/**/*.js'], - rules: { - '@typescript-eslint/no-var-requires': 'off', - '@typescript-eslint/no-require-imports': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/no-unsafe-argument': 'off', - 'no-undef': 'off', - }, - }, - { - files: ['**/*.js', '**/*.mjs', '**/*.ts', './bin/*'], - ignores: ['**/test/*', '**/bench/*'], - plugins: { - 'simple-import-sort': simpleImportSort, - }, - languageOptions: { - parser: tsParser, - ecmaVersion: 'latest', - sourceType: 'script', - parserOptions: { - project: './tsconfig.json', - }, - }, - rules: { - 'no-console': 'off', - '@typescript-eslint/no-unsafe-call': 'off', - '@typescript-eslint/no-unsafe-member-access': 'off', - '@typescript-eslint/no-unsafe-assignment': 'off', - '@typescript-eslint/no-unsafe-return': 'off', - '@typescript-eslint/restrict-plus-operands': 'off', - '@typescript-eslint/restrict-template-expressions': 'off', - '@typescript-eslint/no-non-null-assertion': 'off', - '@typescript-eslint/camelcase': 'off', - '@typescript-eslint/member-naming': 'off', - - '@typescript-eslint/no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - }, - ], - - '@typescript-eslint/member-ordering': [ - 'error', - { - classes: [ - 'public-static-field', - 'protected-static-field', - 'private-static-field', - 'public-static-method', - 'protected-static-method', - 'private-static-method', - 'public-instance-field', - 'protected-instance-field', - 'private-instance-field', - 'public-constructor', - 'protected-constructor', - 'private-constructor', - 'public-instance-method', - 'protected-instance-method', - 'private-instance-method', - ], - }, - ], - - 'curly': ['error', 'all'], - 'eqeqeq': 'error', - 'max-classes-per-file': 'error', - 'no-alert': 'error', - 'no-caller': 'error', - 'no-eval': 'error', - 'no-extend-native': 'error', - 'no-extra-bind': 'error', - 'no-implicit-coercion': 'error', - 'no-labels': 'error', - 'no-new': 'error', - 'no-new-func': 'error', - 'no-new-wrappers': 'error', - 'no-octal-escape': 'error', - 'no-return-assign': 'error', - 'no-self-compare': 'error', - 'no-sequences': 'error', - 'no-throw-literal': 'error', - 'no-unmodified-loop-condition': 'error', - 'no-useless-call': 'error', - 'no-useless-concat': 'error', - 'no-void': 'error', - 'prefer-promise-reject-errors': 'error', - 'radix': ['error', 'always'], - 'no-shadow': 'off', - - 'no-duplicate-imports': 'error', - 'prefer-numeric-literals': 'error', - 'prefer-template': 'error', - 'symbol-description': 'error', - - '@typescript-eslint/array-type': [ - 'error', - { - default: 'array-simple', - }, - ], - - '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], - - '@typescript-eslint/explicit-function-return-type': [ - 'error', - { - allowExpressions: true, - allowTypedFunctionExpressions: true, - allowHigherOrderFunctions: true, - }, - ], - - '@typescript-eslint/interface-name-prefix': 'off', - '@typescript-eslint/no-redundant-type-constituents': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-floating-promises': 'error', - '@typescript-eslint/no-parameter-properties': 'off', - '@typescript-eslint/no-require-imports': 'error', - '@typescript-eslint/no-useless-constructor': 'error', - '@typescript-eslint/prefer-for-of': 'error', - 'prettier/prettier': 'error', - }, - }, -] diff --git a/lua b/lua index be908a7..c6b4848 160000 --- a/lua +++ b/lua @@ -1 +1 @@ -Subproject commit be908a7d4d8130264ad67c5789169769f824c5d1 +Subproject commit c6b484823806e08e1756b1a6066a3ace6f080fae diff --git a/package-lock.json b/package-lock.json index 4e37b2b..f6f2eb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,47 +9,41 @@ "version": "1.16.0", "license": "MIT", "dependencies": { - "@types/emscripten": "1.40.0" + "@types/emscripten": "1.41.5" }, "bin": { "wasmoon": "bin/wasmoon" }, "devDependencies": { - "@eslint/js": "9.20.0", - "@types/node": "22.13.4", - "@typescript-eslint/parser": "8.24.1", - "chai": "5.2.0", - "chai-as-promised": "8.0.1", - "eslint": "9.20.1", - "eslint-config-prettier": "10.0.1", - "eslint-plugin-prettier": "5.2.3", - "eslint-plugin-simple-import-sort": "12.1.1", - "fengari": "0.1.4", - "mocha": "11.1.0", - "prettier": "3.5.1", - "rolldown": "1.0.0-beta.3", + "@types/node": "25.5.0", + "chai": "6.2.2", + "chai-as-promised": "8.0.2", + "fengari": "0.1.5", + "mocha": "11.7.5", + "oxfmt": "0.40.0", + "oxlint": "1.55.0", + "rolldown": "1.0.0-rc.9", "rollup-plugin-copy": "3.5.0", "tslib": "2.8.1", - "typescript": "5.7.3", - "typescript-eslint": "8.24.1" + "typescript": "5.9.3" } }, "node_modules/@emnapi/core": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.3.1.tgz", - "integrity": "sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.0.1", + "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", - "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", "dev": true, "license": "MIT", "optional": true, @@ -58,9 +52,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.1.tgz", - "integrity": "sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "dev": true, "license": "MIT", "optional": true, @@ -68,312 +62,733 @@ "tslib": "^2.4.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "eslint-visitor-keys": "^3.4.3" + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=12" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" }, "funding": { - "url": "https://opencollective.com/eslint" + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "engines": { + "node": ">= 8" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "license": "MIT", "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">= 8" } }, - "node_modules/@eslint/config-array": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz", - "integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==", + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@eslint/object-schema": "^2.1.5", - "debug": "^4.3.1", - "minimatch": "^3.1.2" + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 8" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "node_modules/@oxc-project/types": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", + "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.40.0.tgz", + "integrity": "sha512-S6zd5r1w/HmqR8t0CTnGjFTBLDq2QKORPwriCHxo4xFNuhmOTABGjPaNvCJJVnrKBLsohOeiDX3YqQfJPF+FXw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "*" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/core": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", - "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.40.0.tgz", + "integrity": "sha512-/mbS9UUP/5Vbl2D6osIdcYiP0oie63LKMoTyGj5hyMCK/SFkl3EhtyRAfdjPvuvHC0SXdW6ePaTKkBSq1SNcIw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", - "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.40.0.tgz", + "integrity": "sha512-wRt8fRdfLiEhnRMBonlIbKrJWixoEmn6KCjKE9PElnrSDSXETGZfPb8ee+nQNTobXkCVvVLytp2o0obAsxl78Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.40.0.tgz", + "integrity": "sha512-fzowhqbOE/NRy+AE5ob0+Y4X243WbWzDb00W+pKwD7d9tOqsAFbtWUwIyqqCoCLxj791m2xXIEeLH/3uz7zCCg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.40.0.tgz", + "integrity": "sha512-agZ9ITaqdBjcerRRFEHB8s0OyVcQW8F9ZxsszjxzeSthQ4fcN2MuOtQFWec1ed8/lDa50jSLHVE2/xPmTgtCfQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "*" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/js": { - "version": "9.20.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.20.0.tgz", - "integrity": "sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==", + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.40.0.tgz", + "integrity": "sha512-ZM2oQ47p28TP1DVIp7HL1QoMUgqlBFHey0ksHct7tMXoU5BqjNvPWw7888azzMt25lnyPODVuye1wvNbvVUFOA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz", - "integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==", + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.40.0.tgz", + "integrity": "sha512-RBFPAxRAIsMisKM47Oe6Lwdv6agZYLz02CUhVCD1sOv5ajAcRMrnwCFBPWwGXpazToW2mjnZxFos8TuFjTU15A==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.5.tgz", - "integrity": "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==", + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.40.0.tgz", + "integrity": "sha512-Nb2XbQ+wV3W2jSIihXdPj7k83eOxeSgYP3N/SRXvQ6ZYPIk6Q86qEh5Gl/7OitX3bQoQrESqm1yMLvZV8/J7dA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.10.0", - "levn": "^0.4.1" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.40.0.tgz", + "integrity": "sha512-tGmWhLD/0YMotCdfezlT6tC/MJG/wKpo4vnQ3Cq+4eBk/BwNv7EmkD0VkD5F/dYkT3b8FNU01X2e8vvJuWoM1w==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.40.0.tgz", + "integrity": "sha512-rVbFyM3e7YhkVnp0IVYjaSHfrBWcTRWb60LEcdNAJcE2mbhTpbqKufx0FrhWfoxOrW/+7UJonAOShoFFLigDqQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18.0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.40.0.tgz", + "integrity": "sha512-3ZqBw14JtWeEoLiioJcXSJz8RQyPE+3jLARnYM1HdPzZG4vk+Ua8CUupt2+d+vSAvMyaQBTN2dZK+kbBS/j5mA==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.40.0.tgz", + "integrity": "sha512-JJ4PPSdcbGBjPvb+O7xYm2FmAsKCyuEMYhqatBAHMp/6TA6rVlf9Z/sYPa4/3Bommb+8nndm15SPFRHEPU5qFA==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", - "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.40.0.tgz", + "integrity": "sha512-Kp0zNJoX9Ik77wUya2tpBY3W9f40VUoMQLWVaob5SgCrblH/t2xr/9B2bWHfs0WCefuGmqXcB+t0Lq77sbBmZw==", + "cpu": [ + "s390x" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.40.0.tgz", + "integrity": "sha512-7YTCNzleWTaQTqNGUNQ66qVjpoV6DjbCOea+RnpMBly2bpzrI/uu7Rr+2zcgRfNxyjXaFTVQKaRKjqVdeUfeVA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.6.tgz", - "integrity": "sha512-z8YVS3XszxFTO73iwvFDNpQIzdMmSDTP/mB3E/ucR37V3Sx57hSExcXyMoNwaucWxnsWf4xfbZv0iZ30jr0M4Q==", + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.40.0.tgz", + "integrity": "sha512-hWnSzJ0oegeOwfOEeejYXfBqmnRGHusgtHfCPzmvJvHTwy1s3Neo59UKc1CmpE3zxvrCzJoVHos0rr97GHMNPw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/core": "^1.3.1", - "@emnapi/runtime": "^1.3.1", - "@tybys/wasm-util": "^0.9.0" + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.40.0.tgz", + "integrity": "sha512-28sJC1lR4qtBJGzSRRbPnSW3GxU2+4YyQFE6rCmsUYqZ5XYH8jg0/w+CvEzQ8TuAQz5zLkcA25nFQGwoU0PT3Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">= 8" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.40.0.tgz", + "integrity": "sha512-cDkRnyT0dqwF5oIX1Cv59HKCeZQFbWWdUpXa3uvnHFT2iwYSSZspkhgjXjU6iDp5pFPaAEAe9FIbMoTgkTmKPg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 8" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.40.0.tgz", + "integrity": "sha512-7rPemBJjqm5Gkv6ZRCPvK8lE6AqQ/2z31DRdWazyx2ZvaSgL7QGofHXHNouRpPvNsT9yxRNQJgigsWkc+0qg4w==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.40.0.tgz", + "integrity": "sha512-/Zmj0yTYSvmha6TG1QnoLqVT7ZMRDqXvFXXBQpIjteEwx9qvUYMBH2xbiOFhDeMUJkGwC3D6fdKsFtaqUvkwNA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.55.0.tgz", + "integrity": "sha512-NhvgAhncTSOhRahQSCnkK/4YIGPjTmhPurQQ2dwt2IvwCMTvZRW5vF2K10UBOxFve4GZDMw6LtXZdC2qeuYIVQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.55.0.tgz", + "integrity": "sha512-P9iWRh+Ugqhg+D7rkc7boHX8o3H2h7YPcZHQIgvVBgnua5tk4LR2L+IBlreZs58/95cd2x3/004p5VsQM9z4SA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.55.0.tgz", + "integrity": "sha512-esakkJIt7WFAhT30P/Qzn96ehFpzdZ1mNuzpOb8SCW7lI4oB8VsyQnkSHREM671jfpuBb/o2ppzBCx5l0jpgMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.55.0.tgz", + "integrity": "sha512-xDMFRCCAEK9fOH6As2z8ELsC+VDGSFRHwIKVSilw+xhgLwTDFu37rtmRbmUlx8rRGS6cWKQPTc47AVxAZEVVPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.55.0.tgz", + "integrity": "sha512-mYZqnwUD7ALCRxGenyLd1uuG+rHCL+OTT6S8FcAbVm/ZT2AZMGjvibp3F6k1SKOb2aeqFATmwRykrE41Q0GWVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.55.0.tgz", + "integrity": "sha512-LcX6RYcF9vL9ESGwJW3yyIZ/d/ouzdOKXxCdey1q0XJOW1asrHsIg5MmyKdEBR4plQx+shvYeQne7AzW5f3T1w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.55.0.tgz", + "integrity": "sha512-C+8GS1rPtK+dI7mJFkqoRBkDuqbrNihnyYQsJPS9ez+8zF9JzfvU19lawqt4l/Y23o5uQswE/DORa8aiXUih3w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.55.0.tgz", + "integrity": "sha512-ErLE4XbmcCopA4/CIDiH6J1IAaDOMnf/KSx/aFObs4/OjAAM3sFKWGZ57pNOMxhhyBdcmcXwYymph9GwcpcqgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.55.0.tgz", + "integrity": "sha512-/kp65avi6zZfqEng56TTuhiy3P/3pgklKIdf38yvYeJ9/PgEeRA2A2AqKAKbZBNAqUzrzHhz9jF6j/PZvhJzTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.55.0.tgz", + "integrity": "sha512-A6pTdXwcEEwL/nmz0eUJ6WxmxcoIS+97GbH96gikAyre3s5deC7sts38ZVVowjS2QQFuSWkpA4ZmQC0jZSNvJQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.55.0.tgz", + "integrity": "sha512-clj0lnIN+V52G9tdtZl0LbdTSurnZ1NZj92Je5X4lC7gP5jiCSW+Y/oiDiSauBAD4wrHt2S7nN3pA0zfKYK/6Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.55.0.tgz", + "integrity": "sha512-NNu08pllN5x/O94/sgR3DA8lbrGBnTHsINZZR0hcav1sj79ksTiKKm1mRzvZvacwQ0hUnGinFo+JO75ok2PxYg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.55.0.tgz", + "integrity": "sha512-BvfQz3PRlWZRoEZ17dZCqgQsMRdpzGZomJkVATwCIGhHVVeHJMQdmdXPSjcT1DCNUrOjXnVyj1RGDj5+/Je2+Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.55.0.tgz", + "integrity": "sha512-ngSOoFCSBMKVQd24H8zkbcBNc7EHhjnF1sv3mC9NNXQ/4rRjI/4Dj9+9XoDZeFEkF1SX1COSBXF1b2Pr9rqdEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.55.0.tgz", + "integrity": "sha512-BDpP7W8GlaG7BR6QjGZAleYzxoyKc/D24spZIF2mB3XsfALQJJT/OBmP8YpeTb1rveFSBHzl8T7l0aqwkWNdGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.55.0.tgz", + "integrity": "sha512-PS6GFvmde/pc3fCA2Srt51glr8Lcxhpf6WIBFfLphndjRrD34NEcses4TSxQrEcxYo6qVywGfylM0ZhSCF2gGA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.55.0.tgz", + "integrity": "sha512-P6JcLJGs/q1UOvDLzN8otd9JsH4tsuuPDv+p7aHqHM3PrKmYdmUvkNj4K327PTd35AYcznOCN+l4ZOaq76QzSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.55.0.tgz", + "integrity": "sha512-gzkk4zE2zsE+WmRxFOiAZHpCpUNDFytEakqNXoNHW+PnYEOTPKDdW6nrzgSeTbGKVPXNAKQnRnMgrh7+n3Xueg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 8" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxc-project/types": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.46.0.tgz", - "integrity": "sha512-BHU261xrLasw04d2cToR36F6VV0T7t62rtQUprvBRL4Uru9P23moMkDmZUMSZSQj0fIUTA3oTOTwQ7cc4Av/iw==", + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.55.0.tgz", + "integrity": "sha512-ZFALNow2/og75gvYzNP7qe+rREQ5xunktwA+lgykoozHZ6hw9bqg4fn5j2UvG4gIn1FXqrZHkOAXuPf5+GOYTQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@pkgjs/parseargs": { @@ -387,23 +802,27 @@ "node": ">=14" } }, - "node_modules/@pkgr/core": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", - "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.3.tgz", - "integrity": "sha512-qB1ofY+09nDYYaEi5kVsjqy4cKsVPI9E5bkV46CRrQsTF/BBM29wpvaj8qTRQ41qwInFA5kmqnVVr35yfH7ddw==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", "cpu": [ "arm64" ], @@ -412,12 +831,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.3.tgz", - "integrity": "sha512-Fk+rqyeszMaZK12wItqFDXdUadg+TVQqOPh0fdaCefVebd29N+9fpFrARyo8gReyt/lcnEN4nWgdn7l99R70QA==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", "cpu": [ "x64" ], @@ -426,12 +848,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.3.tgz", - "integrity": "sha512-B7QzJKu53MB/hvwO276AsyxN+p9lfgCkIO94TQB6t3auq3pDCC6u6gdRI1Ydwn6/gpMLiUNCW4mnpxCE5fE5tg==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", "cpu": [ "x64" ], @@ -440,12 +865,15 @@ "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.3.tgz", - "integrity": "sha512-NB5JrXP5dAigDTbvVc6VWiOY3Rr/0u1pi/9LYoBtMYiST7hYOrBPO9lvDF9w/23yKCr1+8PF4wFGR/YxKTNN5Q==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", + "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", "cpu": [ "arm" ], @@ -454,12 +882,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.3.tgz", - "integrity": "sha512-bYyZLXzJ2boZ7CdUuCSAaTcWkVKcBUOL+B86zv+tRyrtk4BIpHF+L+vOg5uPD/PHwrIglxAno5MN4NnpkUj5fQ==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", "cpu": [ "arm64" ], @@ -468,12 +899,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.3.tgz", - "integrity": "sha512-t/jaaFrCSvwX2075jRfa2bwAcsuTtY1/sIT4XqsDg2MVxWQtaUyBx5Mi0pqZKTjdOPnL+f/zoUC9dxT2lUpNmw==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", "cpu": [ "arm64" ], @@ -482,12 +916,49 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.3.tgz", - "integrity": "sha512-EeDNLPU0Xw8ByRWxNLO30AF0fKYkdb/6rH5G073NFBDkj7ggYR/CvsNBjtDeCJ7+I6JG4xUjete2+VeV+GQjiA==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", "cpu": [ "x64" ], @@ -496,12 +967,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.3.tgz", - "integrity": "sha512-iTcAj8FKac3nyQhvFuqKt6Xqu9YNDbe1ew6US2OSN4g3zwfujgylaRCitEG+Uzd7AZfSVVLAfqrxKMa36Sj9Mg==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", "cpu": [ "x64" ], @@ -510,12 +984,32 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.3.tgz", - "integrity": "sha512-sYgbsbyspvVZ2zplqsTxjf2N3e8UQGQnSsN5u4bMX461gY5vAsjUiA4nf1/ztDBMHWT79lF2QNx4csjnjSxMlA==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", + "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", "cpu": [ "wasm32" ], @@ -523,16 +1017,16 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.4" + "@napi-rs/wasm-runtime": "^1.1.1" }, "engines": { - "node": ">=14.21.3" + "node": ">=14.0.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.3.tgz", - "integrity": "sha512-qszMtrWybBLTFaew2WgEBRMlz1B/V8XxU87uezXlKcLW36aoRWR8LspZvqqoBkvJzbQtfOgm1HdTIk/v3Rn7QQ==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", "cpu": [ "arm64" ], @@ -541,26 +1035,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rolldown/binding-win32-ia32-msvc": { - "version": "1.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.3.tgz", - "integrity": "sha512-J+mzAO68VK91coLVuUln/XN0ummIEOODyupZ2BmXY8suBHPVAyLLAP54rlucBPQmzU8fI6DXM2bl2whZ+KEXpQ==", - "cpu": [ - "ia32" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.3.tgz", - "integrity": "sha512-r06rAi+1eStgavGnw+2y4F7gpb0w9ocnKk0Ir7LmegLAkMZ/v4Fjo9jZUrLTLtmI36108v1uvUPrIAFzFOWE7g==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", "cpu": [ "x64" ], @@ -569,12 +1052,22 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", + "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "dev": true, + "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", "optional": true, @@ -583,16 +1076,9 @@ } }, "node_modules/@types/emscripten": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.40.0.tgz", - "integrity": "sha512-MD2JJ25S4tnjnhjWyalMS6K6p0h+zQV6+Ylm+aGbiS8tSn/aHLSGNzBgduj6FB4zH0ax2GRMGYi/8G1uOxhXWA==", - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true, + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", "license": "MIT" }, "node_modules/@types/fs-extra": { @@ -616,13 +1102,6 @@ "@types/node": "*" } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/minimatch": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", @@ -631,266 +1110,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz", - "integrity": "sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.20.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.1.tgz", - "integrity": "sha512-ll1StnKtBigWIGqvYDVuDmXJHVH4zLVot1yQ4fJtLpL7qacwkxJc1T0bptqw+miBQ/QfUbhl1TcQ4accW5KUyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.24.1", - "@typescript-eslint/type-utils": "8.24.1", - "@typescript-eslint/utils": "8.24.1", - "@typescript-eslint/visitor-keys": "8.24.1", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.24.1.tgz", - "integrity": "sha512-Tqoa05bu+t5s8CTZFaGpCH2ub3QeT9YDkXbPd3uQ4SfsLoh1/vv2GEYAioPoxCWJJNsenXlC88tRjwoHNts1oQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.24.1", - "@typescript-eslint/types": "8.24.1", - "@typescript-eslint/typescript-estree": "8.24.1", - "@typescript-eslint/visitor-keys": "8.24.1", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.1.tgz", - "integrity": "sha512-OdQr6BNBzwRjNEXMQyaGyZzgg7wzjYKfX2ZBV3E04hUCBDv3GQCHiz9RpqdUIiVrMgJGkXm3tcEh4vFSHreS2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.24.1", - "@typescript-eslint/visitor-keys": "8.24.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.24.1.tgz", - "integrity": "sha512-/Do9fmNgCsQ+K4rCz0STI7lYB4phTtEXqqCAs3gZW0pnK7lWNkvWd5iW545GSmApm4AzmQXmSqXPO565B4WVrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "8.24.1", - "@typescript-eslint/utils": "8.24.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.1.tgz", - "integrity": "sha512-9kqJ+2DkUXiuhoiYIUvIYjGcwle8pcPpdlfkemGvTObzgmYfJ5d0Qm6jwb4NBXP9W1I5tss0VIAnWFumz3mC5A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.1.tgz", - "integrity": "sha512-UPyy4MJ/0RE648DSKQe9g0VDSehPINiejjA6ElqnFaFIhI6ZEiZAkUI0D5MCk0bQcTf/LVqZStvQ6K4lPn/BRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.24.1", - "@typescript-eslint/visitor-keys": "8.24.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.1.tgz", - "integrity": "sha512-OOcg3PMMQx9EXspId5iktsI3eMaXVwlhC8BvNnX6B5w9a4dVgpkQZuU8Hy67TolKcl+iFWq0XX+jbDGN4xWxjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.24.1", - "@typescript-eslint/types": "8.24.1", - "@typescript-eslint/typescript-estree": "8.24.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.1.tgz", - "integrity": "sha512-EwVHlp5l+2vp8CoqJm9KikPZgi3gbdZAtabKT9KPShGeOcJhsv4Zdo3oc8T8I0uKEmYoU4ItyxbptjF08enaxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.24.1", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@valibot/to-json-schema": { - "version": "1.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@valibot/to-json-schema/-/to-json-schema-1.0.0-beta.4.tgz", - "integrity": "sha512-wXBdCyoqec+NLCl5ihitXzZXD4JAjPK3+HfskSXzfhiNFvKje0A/v1LygqKidUgIbaJtREmq/poJGbaS/0MKuQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "valibot": "^1.0.0 || ^1.0.0-beta.5 || ^1.0.0-rc" - } - }, - "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "undici-types": "~7.18.0" } }, "node_modules/ansi-regex": { @@ -922,33 +1148,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -963,17 +1162,7 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=8" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" + "node": ">=8" } }, "node_modules/balanced-match": { @@ -983,19 +1172,6 @@ "dev": true, "license": "MIT" }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -1026,16 +1202,6 @@ "dev": true, "license": "ISC" }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -1050,33 +1216,27 @@ } }, "node_modules/chai": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", - "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, + "peer": true, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/chai-as-promised": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-8.0.1.tgz", - "integrity": "sha512-OIEJtOL8xxJSH8JJWbIoRjybbzR52iFuDHuF8eb+nTPD6tgXLjRqsgnUGqQfFODxYvq5QdirT0pN9dZ0+Gz6rA==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-8.0.2.tgz", + "integrity": "sha512-1GadL+sEJVLzDjcawPM4kjfnL+p/9vrxiEUonowKOAzvVg0PixJUdtuDzdkDeQhK3zfOE76GqGkZIQ7/Adcrqw==", "dev": true, "license": "MIT", "dependencies": { - "check-error": "^2.0.0" + "check-error": "^2.1.1" }, "peerDependencies": { - "chai": ">= 2.1.2 < 6" + "chai": ">= 2.1.2 < 7" } }, "node_modules/chalk": { @@ -1107,41 +1267,19 @@ } }, "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.0" }, "funding": { "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" } }, "node_modules/cliui": { @@ -1302,27 +1440,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, "node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -1379,291 +1500,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint": { - "version": "9.20.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.20.1.tgz", - "integrity": "sha512-m1mM33o6dBUjxl2qb6wv6nGNwCAsns1eKtaQ4l/NPHeTvhiUPbtdfMyktxN4B3fgHIgsYh1VT3V9txblpQHq+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.0", - "@eslint/core": "^0.11.0", - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.20.0", - "@eslint/plugin-kit": "^0.2.5", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.1", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.2.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-config-prettier": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.1.tgz", - "integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==", - "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "build/bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-prettier": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", - "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.9.1" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-plugin-prettier" - }, - "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "eslint-config-prettier": "*", - "prettier": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "eslint-config-prettier": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-simple-import-sort": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-12.1.1.tgz", - "integrity": "sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": ">=5.0.0" - } - }, - "node_modules/eslint-scope": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", - "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/@eslint/core": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.11.0.tgz", - "integrity": "sha512-DWUB2pksgNEb6Bz2fggIy1wh6fGgZP4Xyy/Mt0QZPiloKKXerbqq9D3SBQTlCRYOrcRPu4vuz+CGjwdfqxnoWA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.14.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -1694,20 +1530,6 @@ "node": ">= 6" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, "node_modules/fastq": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", @@ -1719,28 +1541,15 @@ } }, "node_modules/fengari": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/fengari/-/fengari-0.1.4.tgz", - "integrity": "sha512-6ujqUuiIYmcgkGz8MGAdERU57EIluGGPSUgGPTsco657EHa+srq0S3/YUl/r9kx1+D+d4rGfYObd+m8K22gB1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "readline-sync": "^1.4.9", - "sprintf-js": "^1.1.1", - "tmp": "^0.0.33" - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/fengari/-/fengari-0.1.5.tgz", + "integrity": "sha512-0DS4Nn4rV8qyFlQCpKK8brT61EUtswynrpfFTcgLErcilBIBskSMQ86fO2WVuybr14ywyKdRjv91FiRZwnEuvQ==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" + "readline-sync": "^1.4.10", + "sprintf-js": "^1.1.3", + "tmp": "^0.2.5" } }, "node_modules/fill-range": { @@ -1783,27 +1592,6 @@ "flat": "cli.js" } }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", - "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", - "dev": true, - "license": "ISC" - }, "node_modules/foreground-child": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", @@ -1843,21 +1631,6 @@ "dev": true, "license": "ISC" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1879,40 +1652,14 @@ "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/globby": { @@ -1988,13 +1735,6 @@ "dev": true, "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2025,33 +1765,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -2071,19 +1784,6 @@ "dev": true, "license": "ISC" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2127,6 +1827,16 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-obj": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", @@ -2196,27 +1906,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -2227,30 +1916,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2267,13 +1932,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -2291,13 +1949,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/loupe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", - "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -2369,29 +2020,30 @@ } }, "node_modules/mocha": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.1.0.tgz", - "integrity": "sha512-8uJR5RTC2NgpY3GrYcgpZrsEd9zKbPDpob1RezyR2upGHRQtHWofmzTMzTMSV6dru3tj5Ukt0+Vnq1qhFEEwAg==", + "version": "11.7.5", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", + "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", "dev": true, "license": "MIT", "dependencies": { - "ansi-colors": "^4.1.3", "browser-stdout": "^1.3.1", - "chokidar": "^3.5.3", + "chokidar": "^4.0.1", "debug": "^4.3.5", - "diff": "^5.2.0", + "diff": "^7.0.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", "glob": "^10.4.5", "he": "^1.2.0", + "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", - "minimatch": "^5.1.6", + "minimatch": "^9.0.5", "ms": "^2.1.3", + "picocolors": "^1.1.1", "serialize-javascript": "^6.0.2", "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", - "workerpool": "^6.5.1", + "workerpool": "^9.2.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" @@ -2404,19 +2056,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/mocha/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -2440,23 +2079,6 @@ "dev": true, "license": "MIT" }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2467,32 +2089,89 @@ "wrappy": "1" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "node_modules/oxfmt": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.40.0.tgz", + "integrity": "sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ==", "dev": true, "license": "MIT", "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" }, "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.40.0", + "@oxfmt/binding-android-arm64": "0.40.0", + "@oxfmt/binding-darwin-arm64": "0.40.0", + "@oxfmt/binding-darwin-x64": "0.40.0", + "@oxfmt/binding-freebsd-x64": "0.40.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.40.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.40.0", + "@oxfmt/binding-linux-arm64-gnu": "0.40.0", + "@oxfmt/binding-linux-arm64-musl": "0.40.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.40.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.40.0", + "@oxfmt/binding-linux-riscv64-musl": "0.40.0", + "@oxfmt/binding-linux-s390x-gnu": "0.40.0", + "@oxfmt/binding-linux-x64-gnu": "0.40.0", + "@oxfmt/binding-linux-x64-musl": "0.40.0", + "@oxfmt/binding-openharmony-arm64": "0.40.0", + "@oxfmt/binding-win32-arm64-msvc": "0.40.0", + "@oxfmt/binding-win32-ia32-msvc": "0.40.0", + "@oxfmt/binding-win32-x64-msvc": "0.40.0" + } + }, + "node_modules/oxlint": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.55.0.tgz", + "integrity": "sha512-T+FjepiyWpaZMhekqRpH8Z3I4vNM610p6w+Vjfqgj5TZUxHXl7N8N5IPvmOU8U4XdTRxqtNNTh9Y4hLtr7yvFg==", "dev": true, "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, "engines": { - "node": ">=0.10.0" + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.55.0", + "@oxlint/binding-android-arm64": "1.55.0", + "@oxlint/binding-darwin-arm64": "1.55.0", + "@oxlint/binding-darwin-x64": "1.55.0", + "@oxlint/binding-freebsd-x64": "1.55.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.55.0", + "@oxlint/binding-linux-arm-musleabihf": "1.55.0", + "@oxlint/binding-linux-arm64-gnu": "1.55.0", + "@oxlint/binding-linux-arm64-musl": "1.55.0", + "@oxlint/binding-linux-ppc64-gnu": "1.55.0", + "@oxlint/binding-linux-riscv64-gnu": "1.55.0", + "@oxlint/binding-linux-riscv64-musl": "1.55.0", + "@oxlint/binding-linux-s390x-gnu": "1.55.0", + "@oxlint/binding-linux-x64-gnu": "1.55.0", + "@oxlint/binding-linux-x64-musl": "1.55.0", + "@oxlint/binding-openharmony-arm64": "1.55.0", + "@oxlint/binding-win32-arm64-msvc": "1.55.0", + "@oxlint/binding-win32-ia32-msvc": "1.55.0", + "@oxlint/binding-win32-x64-msvc": "1.55.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.15.0" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + } } }, "node_modules/p-limit": { @@ -2534,19 +2213,6 @@ "dev": true, "license": "BlueOak-1.0.0" }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2604,64 +2270,12 @@ "node": ">=8" } }, - "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz", - "integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } + "license": "ISC" }, "node_modules/queue-microtask": { "version": "1.2.3", @@ -2695,29 +2309,17 @@ } }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">= 14.18.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/readline-sync": { @@ -2740,16 +2342,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -2762,40 +2354,37 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-beta.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.3.tgz", - "integrity": "sha512-DBpF1K8tSwU/0dQ7zL9BYcje0/GjO5lgfdEW0rHHFfGjGDh8TBVNlokfEXtdt/IoJOiTdtySfsrgarLJkZmZTQ==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", + "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "0.46.0", - "@valibot/to-json-schema": "1.0.0-beta.4", - "valibot": "1.0.0-beta.12" + "@oxc-project/types": "=0.115.0", + "@rolldown/pluginutils": "1.0.0-rc.9" }, "bin": { - "rolldown": "bin/cli.js" - }, - "optionalDependencies": { - "@rolldown/binding-darwin-arm64": "1.0.0-beta.3", - "@rolldown/binding-darwin-x64": "1.0.0-beta.3", - "@rolldown/binding-freebsd-x64": "1.0.0-beta.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.3", - "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.3", - "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.3", - "@rolldown/binding-linux-x64-musl": "1.0.0-beta.3", - "@rolldown/binding-wasm32-wasi": "1.0.0-beta.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.3", - "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.3", - "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.3" + "rolldown": "bin/cli.mjs" }, - "peerDependencies": { - "@babel/runtime": ">=7" + "engines": { + "node": "^20.19.0 || >=22.12.0" }, - "peerDependenciesMeta": { - "@babel/runtime": { - "optional": true - } + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-x64": "1.0.0-rc.9", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" } }, "node_modules/rollup-plugin-copy": { @@ -2860,19 +2449,6 @@ ], "license": "MIT" }, - "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -3066,34 +2642,24 @@ "node": ">=8" } }, - "node_modules/synckit": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz", - "integrity": "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==", + "node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", "dev": true, "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.1.0", - "tslib": "^2.6.2" - }, "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" + "node": "^20.0.0 || >=22.0.0" } }, "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, "license": "MIT", - "dependencies": { - "os-tmpdir": "~1.0.2" - }, "engines": { - "node": ">=0.6.0" + "node": ">=14.14" } }, "node_modules/to-regex-range": { @@ -3109,19 +2675,6 @@ "node": ">=8.0" } }, - "node_modules/ts-api-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", - "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3129,25 +2682,13 @@ "dev": true, "license": "0BSD" }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3156,33 +2697,10 @@ "node": ">=14.17" } }, - "node_modules/typescript-eslint": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.24.1.tgz", - "integrity": "sha512-cw3rEdzDqBs70TIcb0Gdzbt6h11BSs2pS0yaq7hDWDBtCCSei1pPSUXE9qUdQ/Wm9NgFg8mKtMt1b8fTHIl1jA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.24.1", - "@typescript-eslint/parser": "8.24.1", - "@typescript-eslint/utils": "8.24.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" - } - }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -3196,31 +2714,6 @@ "node": ">= 4.0.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/valibot": { - "version": "1.0.0-beta.12", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.0.0-beta.12.tgz", - "integrity": "sha512-j3WIxJ0pmUFMfdfUECn3YnZPYOiG0yHYcFEa/+RVgo0I+MXE3ToLt7gNRLtY5pwGfgNmsmhenGZfU5suu9ijUA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "typescript": ">=5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3237,20 +2730,10 @@ "node": ">= 8" } }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/workerpool": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", - "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", "dev": true, "license": "Apache-2.0" }, diff --git a/package.json b/package.json index 746bc87..3afca7c 100755 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "luatests": "node --experimental-import-meta-resolve test/luatests.js", "build": "rolldown -c && tsc -d --emitDeclarationOnly --rootDir src --declarationDir dist", "clean": "rm -rf dist build", - "lint": "prettier --write . && eslint . --fix --cache", - "lint:nofix": "eslint ." + "lint": "oxfmt --write . && oxlint --config .oxlintrc.json -D suspicious --import-plugin --node-plugin --promise-plugin --fix .", + "lint:nofix": "oxfmt --check . && oxlint --config .oxlintrc.json -D suspicious --import-plugin --node-plugin --promise-plugin ." }, "files": [ "bin/*", @@ -40,25 +40,19 @@ "webassembly" ], "devDependencies": { - "@eslint/js": "9.20.0", - "@types/node": "22.13.4", - "@typescript-eslint/parser": "8.24.1", - "chai": "5.2.0", - "chai-as-promised": "8.0.1", - "eslint": "9.20.1", - "eslint-config-prettier": "10.0.1", - "eslint-plugin-prettier": "5.2.3", - "eslint-plugin-simple-import-sort": "12.1.1", - "fengari": "0.1.4", - "mocha": "11.1.0", - "prettier": "3.5.1", - "rolldown": "1.0.0-beta.3", + "@types/node": "25.5.0", + "chai": "6.2.2", + "chai-as-promised": "8.0.2", + "fengari": "0.1.5", + "mocha": "11.7.5", + "oxfmt": "0.40.0", + "oxlint": "1.55.0", + "rolldown": "1.0.0-rc.9", "rollup-plugin-copy": "3.5.0", "tslib": "2.8.1", - "typescript": "5.7.3", - "typescript-eslint": "8.24.1" + "typescript": "5.9.3" }, "dependencies": { - "@types/emscripten": "1.40.0" + "@types/emscripten": "1.41.5" } } diff --git a/src/module.ts b/src/module.ts index 1f0d8ad..cdd3ae0 100755 --- a/src/module.ts +++ b/src/module.ts @@ -12,11 +12,11 @@ interface LuaEmscriptenModule extends EmscriptenModule { setValue: typeof setValue getValue: typeof getValue FS: typeof FS & { - mkdirTree: (path: string) => void filesystems: { NODEFS: Emscripten.FileSystemType MEMFS: Emscripten.FileSystemType } + mkdirTree: (path: string) => void } PATH: { dirname: (typeof import('node:path'))['dirname'] @@ -56,8 +56,6 @@ export default class LuaModule { const child_process = !isBrowser && opts.fs === 'node' && typeof process !== 'undefined' ? await import('node:child_process') : null const module: LuaEmscriptenModule = await initWasmModule({ - print: opts.stdout, - printErr: opts.stderr, locateFile: (path: string, scriptDirectory: string) => { return opts.wasmFile || scriptDirectory + path }, @@ -69,7 +67,9 @@ export default class LuaModule { if (fs && child_process) { let rootdirs: string[] if (process.platform === 'win32') { - const stdout = child_process.execSync('wmic logicaldisk get name', { encoding: 'utf8' }) + const stdout = child_process.execSync('wmic logicaldisk get name', { + encoding: 'utf8', + }) const drives = stdout .split('\n') .map((line) => line.trim()) @@ -105,33 +105,31 @@ export default class LuaModule { initializedModule.FS.chdir(process.cwd().replace(/\\|\\\\/g, '/')) } - if (opts.stdin) { + if (opts.stdin || opts.stdout || opts.stderr) { let bufferedInput: number[] | undefined initializedModule.FS.init( - () => { - if (!opts.stdin) { - throw new Error('stdin is not defined, it was probably mutated from original options') - } - - if (!bufferedInput) { - const stdin = opts.stdin() - if (typeof stdin === 'string') { - bufferedInput = initializedModule.intArrayFromString(stdin, true).concat([0]) - } else { - throw new Error('stdin must return a string') - } - } - - if (bufferedInput.length === 0) { - bufferedInput = undefined - return null - } - - const item = bufferedInput.shift() - return !item || item === 0 ? null : item - }, - null, - null, + opts.stdin + ? () => { + if (!bufferedInput) { + const input = opts.stdin?.() + if (typeof input === 'string') { + bufferedInput = initializedModule.intArrayFromString(input, true).concat([0]) + } else { + throw new Error('stdin must return a string') + } + } + + if (bufferedInput.length === 0) { + bufferedInput = undefined + return null + } + + const item = bufferedInput.shift() + return !item || item === 0 ? null : item + } + : null, + createOutputWriter(opts.stdout), + createOutputWriter(opts.stderr), ) } }, @@ -574,3 +572,22 @@ export default class LuaModule { } } } + +function createOutputWriter(writer?: (content: string) => void): ((charCode: number | null) => void) | null { + if (!writer) { + return null + } + + let buffer = '' + return (charCode: number | null): void => { + if (charCode === null || charCode === 10) { + writer(buffer) + buffer = '' + return + } + + if (charCode !== 13) { + buffer += String.fromCharCode(charCode) + } + } +} diff --git a/src/type-extensions/function.ts b/src/type-extensions/function.ts index 28b5283..9625653 100644 --- a/src/type-extensions/function.ts +++ b/src/type-extensions/function.ts @@ -79,28 +79,28 @@ class FunctionTypeExtension extends TypeExtension + const { target, options: decorationOptions } = thread.lua.getRef(refPointer) as Decoration const argsQuantity = calledThread.getTop() const args = [] - if (options.receiveThread) { + if (decorationOptions.receiveThread) { args.push(calledThread) } - if (options.receiveArgsQuantity) { + if (decorationOptions.receiveArgsQuantity) { args.push(argsQuantity) } else { for (let i = 1; i <= argsQuantity; i++) { const value = calledThread.getValue(i) - if (i !== 1 || !options?.self || value !== options.self) { + if (i !== 1 || !decorationOptions?.self || value !== decorationOptions.self) { args.push(value) } } } try { - const result = target.apply(options?.self, args) + const result = target.apply(decorationOptions?.self, args) if (result === undefined) { return 0 diff --git a/src/type-extensions/promise.ts b/src/type-extensions/promise.ts index ea8da1b..2130c6b 100644 --- a/src/type-extensions/promise.ts +++ b/src/type-extensions/promise.ts @@ -58,6 +58,7 @@ class PromiseTypeExtension extends TypeExtension> { const awaitPromise = self .then((res) => { promiseResult = { status: 'fulfilled', value: res } + return res }) .catch((err) => { promiseResult = { status: 'rejected', value: err } diff --git a/src/type-extensions/table.ts b/src/type-extensions/table.ts index 3d08304..6fb4335 100644 --- a/src/type-extensions/table.ts +++ b/src/type-extensions/table.ts @@ -28,7 +28,7 @@ class TableTypeExtension extends TypeExtension { if (!table) { const keys = this.readTableKeys(thread, index) - const isSequential = keys.length > 0 && keys.every((key, index) => key === String(index + 1)) + const isSequential = keys.length > 0 && keys.every((key, keyIndex) => key === String(keyIndex + 1)) table = isSequential ? [] : {} seenMap.set(pointer, table) diff --git a/tsconfig.json b/tsconfig.json index 625eae6..af4b756 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,6 @@ "strict": true, "resolveJsonModule": true }, - "include": ["src/**/*", "test/**/*", "bench/**/*", "eslint.config.js"], + "include": ["src/**/*", "test/**/*", "bench/**/*"], "exclude": ["node_modules"] } diff --git a/utils/build-wasm.js b/utils/build-wasm.js index ff97b26..4913605 100644 --- a/utils/build-wasm.js +++ b/utils/build-wasm.js @@ -38,8 +38,7 @@ if (isUnix) { try { execSync('docker --version', { encoding: 'utf-8' }) -} -catch (error) { +} catch (error) { console.error('Docker is not installed or not in your PATH. Please install Docker to build the WASM file.') process.exit(1) } diff --git a/utils/build-wasm.sh b/utils/build-wasm.sh index 4818b30..74d02c2 100755 --- a/utils/build-wasm.sh +++ b/utils/build-wasm.sh @@ -31,6 +31,11 @@ emcc \ 'UTF8ToString', \ 'HEAPU32' ]" \ + -s DEFAULT_LIBRARY_FUNCS_TO_INCLUDE="[ + '\$FS_mkdirTree', \ + '\$PATH', \ + '\$PATH_FS' + ]" \ -s INCOMING_MODULE_JS_API="[ 'locateFile', \ 'preRun', \ @@ -44,8 +49,6 @@ emcc \ -s ALLOW_MEMORY_GROWTH=1 \ -s STRICT=1 \ -s EXPORT_ES6=1 \ - -s NODEJS_CATCH_EXIT=0 \ - -s NODEJS_CATCH_REJECTION=0 \ -s MALLOC=emmalloc \ -s STACK_SIZE=1MB \ -s WASM_BIGINT \ From 9fd1b195b3dfc3ac03a52b8b65076e5e92605d22 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Fri, 13 Mar 2026 20:52:22 -0300 Subject: [PATCH 29/52] fix lua 5.5.1 implementation --- src/global.ts | 24 +++++++++++++++++++----- src/module.ts | 17 +++++++++++------ src/types.ts | 2 +- test/luatests.js | 21 +++++++++++++++++++++ utils/build-wasm.sh | 5 ++--- utils/create-bindings.sh | 2 +- 6 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/global.ts b/src/global.ts index fd28272..e5df71d 100755 --- a/src/global.ts +++ b/src/global.ts @@ -36,7 +36,11 @@ export default class Global extends Thread { const endMemoryDelta = pointer ? newSize - oldSize : newSize const endMemory = memoryStats.memoryUsed + endMemoryDelta - if (newSize > oldSize && memoryStats.memoryMax && endMemory > memoryStats.memoryMax) { + if ( + newSize > oldSize && + memoryStats.memoryMax && + endMemory > memoryStats.memoryMax + ) { return 0 } @@ -49,7 +53,11 @@ export default class Global extends Thread { 'iiiii', ) - const address = cmodule.lua_newstate(allocatorFunctionPointer, null) + const address = cmodule.lua_newstate( + allocatorFunctionPointer, + null, + ((Date.now() >>> 0) ^ Math.floor(Math.random() * 0x100000000)) >>> 0, + ) if (!address) { cmodule._emscripten.removeFunction(allocatorFunctionPointer) throw new Error('lua_newstate returned a null pointer') @@ -171,13 +179,17 @@ export default class Global extends Thread { const type = this.lua.lua_getglobal(this.address, name) try { if (type !== LuaType.Table) { - throw new TypeError(`Unexpected type in ${name}. Expected ${LuaType[LuaType.Table]}. Got ${LuaType[type]}.`) + throw new TypeError( + `Unexpected type in ${name}. Expected ${LuaType[LuaType.Table]}. Got ${LuaType[type]}.`, + ) } callback(startStackTop + 1) } finally { // +1 for the table if (this.getTop() !== startStackTop + 1) { - console.warn(`getTable: expected stack size ${startStackTop + 1} got ${this.getTop()}`) + console.warn( + `getTable: expected stack size ${startStackTop + 1} got ${this.getTop()}`, + ) } this.setTop(startStackTop) } @@ -209,7 +221,9 @@ export default class Global extends Thread { private getMemoryStatsRef(): LuaMemoryStats { if (!this.memoryStats) { - throw new Error('Memory allocations is not being traced, please build engine with { traceAllocations: true }') + throw new Error( + 'Memory allocations is not being traced, please build engine with { traceAllocations: true }', + ) } return this.memoryStats diff --git a/src/module.ts b/src/module.ts index cdd3ae0..a4bde12 100755 --- a/src/module.ts +++ b/src/module.ts @@ -180,6 +180,7 @@ export default class LuaModule { public luaL_getsubtable: (L: LuaState, idx: number, fname: string | null) => number public luaL_traceback: (L: LuaState, L1: LuaState, msg: string | null, level: number) => void public luaL_requiref: (L: LuaState, modname: string | null, openf: number, glb: number) => void + public luaL_openselectedlibs: (L: LuaState, load: number, preload: number) => void public luaL_buffinit: (L: LuaState, B: number | null) => void public luaL_prepbuffsize: (B: number | null, sz: number) => string public luaL_addlstring: (B: number | null, s: string | null, l: number) => void @@ -188,9 +189,10 @@ export default class LuaModule { public luaL_pushresult: (B: number | null) => void public luaL_pushresultsize: (B: number | null, sz: number) => void public luaL_buffinitsize: (L: LuaState, B: number | null, sz: number) => string - public lua_newstate: (f: number | null, ud: number | null) => LuaState + public lua_newstate: (f: number | null, ud: number | null, seed: number) => LuaState public lua_close: (L: LuaState) => void public lua_newthread: (L: LuaState) => LuaState + public lua_closethread: (L: LuaState, from: LuaState | null) => LuaReturn public lua_resetthread: (L: LuaState) => LuaReturn public lua_atpanic: (L: LuaState, panicf: number) => number public lua_version: (L: LuaState) => number @@ -281,7 +283,7 @@ export default class LuaModule { public lua_gethook: (L: LuaState) => number public lua_gethookmask: (L: LuaState) => number public lua_gethookcount: (L: LuaState) => number - public lua_setcstacklimit: (L: LuaState, limit: number) => number + public lua_setcstacklimit: (_L: LuaState, _limit: number) => number public luaopen_base: (L: LuaState) => number public luaopen_coroutine: (L: LuaState) => number public luaopen_table: (L: LuaState) => number @@ -337,6 +339,7 @@ export default class LuaModule { this.luaL_getsubtable = this.cwrap('luaL_getsubtable', 'number', ['number', 'number', 'string']) this.luaL_traceback = this.cwrap('luaL_traceback', null, ['number', 'number', 'string', 'number']) this.luaL_requiref = this.cwrap('luaL_requiref', null, ['number', 'string', 'number', 'number']) + this.luaL_openselectedlibs = this.cwrap('luaL_openselectedlibs', null, ['number', 'number', 'number']) this.luaL_buffinit = this.cwrap('luaL_buffinit', null, ['number', 'number']) this.luaL_prepbuffsize = this.cwrap('luaL_prepbuffsize', 'string', ['number', 'number']) this.luaL_addlstring = this.cwrap('luaL_addlstring', null, ['number', 'string', 'number']) @@ -345,10 +348,11 @@ export default class LuaModule { this.luaL_pushresult = this.cwrap('luaL_pushresult', null, ['number']) this.luaL_pushresultsize = this.cwrap('luaL_pushresultsize', null, ['number', 'number']) this.luaL_buffinitsize = this.cwrap('luaL_buffinitsize', 'string', ['number', 'number', 'number']) - this.lua_newstate = this.cwrap('lua_newstate', 'number', ['number', 'number']) + this.lua_newstate = this.cwrap('lua_newstate', 'number', ['number', 'number', 'number']) this.lua_close = this.cwrap('lua_close', null, ['number']) this.lua_newthread = this.cwrap('lua_newthread', 'number', ['number']) - this.lua_resetthread = this.cwrap('lua_resetthread', 'number', ['number']) + this.lua_closethread = this.cwrap('lua_closethread', 'number', ['number', 'number']) + this.lua_resetthread = (L) => this.lua_closethread(L, null) this.lua_atpanic = this.cwrap('lua_atpanic', 'number', ['number', 'number']) this.lua_version = this.cwrap('lua_version', 'number', ['number']) this.lua_absindex = this.cwrap('lua_absindex', 'number', ['number', 'number']) @@ -438,7 +442,8 @@ export default class LuaModule { this.lua_gethook = this.cwrap('lua_gethook', 'number', ['number']) this.lua_gethookmask = this.cwrap('lua_gethookmask', 'number', ['number']) this.lua_gethookcount = this.cwrap('lua_gethookcount', 'number', ['number']) - this.lua_setcstacklimit = this.cwrap('lua_setcstacklimit', 'number', ['number', 'number']) + // Deprecated in Lua 5.5; keep the JS API surface as a no-op compatibility shim. + this.lua_setcstacklimit = () => 0 this.luaopen_base = this.cwrap('luaopen_base', 'number', ['number']) this.luaopen_coroutine = this.cwrap('luaopen_coroutine', 'number', ['number']) this.luaopen_table = this.cwrap('luaopen_table', 'number', ['number']) @@ -449,7 +454,7 @@ export default class LuaModule { this.luaopen_math = this.cwrap('luaopen_math', 'number', ['number']) this.luaopen_debug = this.cwrap('luaopen_debug', 'number', ['number']) this.luaopen_package = this.cwrap('luaopen_package', 'number', ['number']) - this.luaL_openlibs = this.cwrap('luaL_openlibs', null, ['number']) + this.luaL_openlibs = (L) => this.luaL_openselectedlibs(L, -1, 0) } public lua_remove(luaState: LuaState, index: number): void { diff --git a/src/types.ts b/src/types.ts index 1e12c75..393c812 100755 --- a/src/types.ts +++ b/src/types.ts @@ -36,7 +36,7 @@ export const PointerSize = 4 export const LUA_MULTRET = -1 export const LUAI_MAXSTACK = 1000000 -export const LUA_REGISTRYINDEX = -LUAI_MAXSTACK - 1000 +export const LUA_REGISTRYINDEX = -(Math.trunc(0x7fffffff / 2) + 1000) export enum LuaType { None = -1, diff --git a/test/luatests.js b/test/luatests.js index cfe2df7..27460f6 100644 --- a/test/luatests.js +++ b/test/luatests.js @@ -6,6 +6,27 @@ const lua = await Lua.load() const testsPath = import.meta.resolve('../lua/testes') const filePath = fileURLToPath(typeof testsPath === 'string' ? testsPath : await Promise.resolve(testsPath)) +if (!lua.filesystem.analyzePath('/dev/full').exists) { + const deviceMode = lua.filesystem.lookupPath('/dev/null').node.mode + const fullDevice = lua.filesystem.makedev(64, 0) + lua.filesystem.registerDevice(fullDevice, { + open(stream) { + stream.seekable = false + }, + close() {}, + read() { + return 0 + }, + write() { + throw new lua.filesystem.ErrnoError(28) + }, + llseek() { + throw new lua.filesystem.ErrnoError(70) + }, + }) + lua.filesystem.mkdev('/dev/full', deviceMode, fullDevice) +} + for await (const file of glob(`${filePath}/**/*.lua`)) { const relativeFile = file.replace(`${filePath}/`, '') lua.mountFile(relativeFile, await readFile(file)) diff --git a/utils/build-wasm.sh b/utils/build-wasm.sh index 74d02c2..b044209 100755 --- a/utils/build-wasm.sh +++ b/utils/build-wasm.sh @@ -102,7 +102,7 @@ emcc \ '_lua_newstate', \ '_lua_close', \ '_lua_newthread', \ - '_lua_resetthread', \ + '_lua_closethread', \ '_lua_atpanic', \ '_lua_version', \ '_lua_absindex', \ @@ -192,7 +192,6 @@ emcc \ '_lua_gethook', \ '_lua_gethookmask', \ '_lua_gethookcount', \ - '_lua_setcstacklimit', \ '_luaopen_base', \ '_luaopen_coroutine', \ '_luaopen_table', \ @@ -203,6 +202,6 @@ emcc \ '_luaopen_math', \ '_luaopen_debug', \ '_luaopen_package', \ - '_luaL_openlibs' \ + '_luaL_openselectedlibs' \ ]" \ ${LUA_SRC} diff --git a/utils/create-bindings.sh b/utils/create-bindings.sh index b47efb3..036c15e 100755 --- a/utils/create-bindings.sh +++ b/utils/create-bindings.sh @@ -1,3 +1,3 @@ #!/bin/sh -e -find ../lua/ -name "*.h" | ./create-bindings.js +find ../lua/ -name "*.h" | ./create-bindings.cjs From d41aca81f399dcb82409d3941e5af24d570cbc44 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Sat, 14 Mar 2026 11:57:25 -0300 Subject: [PATCH 30/52] add a test to ensure no regressions on 64bit numbers --- test/engine.test.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/engine.test.js b/test/engine.test.js index 8b1fdd1..05fbcfb 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -717,6 +717,25 @@ describe('State', () => { expect(res).to.be.equal('1689031554550') }) + it('64-bit integers pushed through the raw Lua API should keep integer semantics', async () => { + const state = await getState() + const value = 9223372036854775807n + + state.global.lua.lua_pushinteger(state.global.address, value) + state.global.lua.lua_setglobal(state.global.address, 'value') + + const asString = await state.doString(`return tostring(value)`) + const asFormatted = await state.doString(`return ("%d"):format(value)`) + + state.global.lua.lua_getglobal(state.global.address, 'value') + const roundTrip = state.global.lua.lua_tointegerx(state.global.address, -1, null) + state.global.pop() + + expect(asString).to.be.equal('9223372036854775807') + expect(asFormatted).to.be.equal('9223372036854775807') + expect(roundTrip).to.be.equal(value) + }) + it('yielding in a JS callback into Lua does not break lua state', async () => { // When yielding within a callback the error 'attempt to yield across a C-call boundary'. // This test just checks that throwing that error still allows the lua global to be From a23e34d83f5e157cab5244eea37d8fe021cd6195 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Sat, 14 Mar 2026 13:59:52 -0300 Subject: [PATCH 31/52] optimize build size --- .github/workflows/test.yml | 6 +- bench/comparisons.js | 84 +++--- bench/index.js | 20 ++ bench/steps.js | 216 +++++++-------- bench/utils.js | 240 +++++++++++++++++ package-lock.json | 528 ------------------------------------- package.json | 2 +- rolldown.config.ts | 22 +- utils/build-wasm.sh | 2 +- 9 files changed, 423 insertions(+), 697 deletions(-) create mode 100644 bench/index.js create mode 100644 bench/utils.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 119e830..e18e2c3 100755 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,15 +10,15 @@ jobs: strategy: matrix: - node-version: [22] + node-version: [22, 24] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: recursive - uses: mymindstorm/setup-emsdk@v14 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - run: npm ci diff --git a/bench/comparisons.js b/bench/comparisons.js index 1a72e9a..5b6dd14 100644 --- a/bench/comparisons.js +++ b/bench/comparisons.js @@ -1,62 +1,52 @@ -import { readFileSync } from 'fs' -import path from 'path' -import { performance } from 'perf_hooks' import { Lua } from '../dist/index.js' import fengari from 'fengari' +import { isMainModule, parseBenchOptions, readBenchAsset, runBenchmarks } from './utils.js' -const heapsort = readFileSync(path.resolve(import.meta.dirname, 'heapsort.lua'), 'utf-8') +const heapsort = readBenchAsset('heapsort.lua') -function calculateStats(times) { - const n = times.length - const avg = times.reduce((sum, t) => sum + t, 0) / n - const stdDev = Math.sqrt(times.reduce((sum, t) => sum + (t - avg) ** 2, 0) / n) - return { avg, stdDev } -} - -async function benchmark(name, iterations, warmup, fn) { - console.log(`\nBenchmarking ${name}...`) - - for (let i = 0; i < warmup; i++) { - await fn() +function runFengariIteration() { + const state = fengari.lauxlib.luaL_newstate() + try { + fengari.lualib.luaL_openlibs(state) + assertStatus(fengari.lauxlib.luaL_loadstring(state, fengari.to_luastring(heapsort)), 'Fengari load') + assertStatus(fengari.lua.lua_pcallk(state, 0, 1, 0, 0, null), 'Fengari compile') + assertStatus(fengari.lua.lua_pcallk(state, 0, 1, 0, 0, null), 'Fengari execute') + } finally { + fengari.lua.lua_close(state) } +} - const times = [] - for (let i = 0; i < iterations; i++) { - const start = performance.now() - await fn() - const end = performance.now() - times.push(end - start) +function createWasmoonIteration(lua) { + return function runWasmoonIteration() { + const state = lua.createState() + try { + assertStatus(state.global.lua.luaL_loadstring(state.global.address, heapsort), 'Wasmoon load') + assertStatus(state.global.lua.lua_pcallk(state.global.address, 0, 1, 0, 0, null), 'Wasmoon compile') + assertStatus(state.global.lua.lua_pcallk(state.global.address, 0, 1, 0, 0, null), 'Wasmoon execute') + } finally { + state.global.close() + } } - - const { avg, stdDev } = calculateStats(times) - console.log(`${name}: ${iterations} iterations | avg: ${avg.toFixed(3)} ms | std dev: ${stdDev.toFixed(3)} ms`) } -async function benchmarkFengari(iterations, warmup) { - function runFengariIteration() { - const state = fengari.lauxlib.luaL_newstate() - fengari.lualib.luaL_openlibs(state) - fengari.lauxlib.luaL_loadstring(state, fengari.to_luastring(heapsort)) - fengari.lua.lua_callk(state, 0, 1, 0, null) - fengari.lua.lua_callk(state, 0, 0, 0, null) +function assertStatus(status, label) { + if (status !== 0) { + throw new Error(`${label} failed with status ${status}`) } - await benchmark('Fengari', iterations, warmup, runFengariIteration) } -async function benchmarkWasmoon(iterations, warmup) { +export async function runComparisonBench(options = {}) { const lua = await Lua.load() - - async function runWasmoonIteration() { - const state = lua.createState() - state.global.lua.luaL_loadstring(state.global.address, heapsort) - state.global.lua.lua_callk(state.global.address, 0, 1, 0, null) - state.global.lua.lua_callk(state.global.address, 0, 0, 0, null) - } - await benchmark('Wasmoon', iterations, warmup, runWasmoonIteration) + return runBenchmarks({ + title: 'Comparison benchmarks', + benches: [ + { name: 'Fengari heapsort', run: runFengariIteration }, + { name: 'Wasmoon heapsort', run: createWasmoonIteration(lua) }, + ], + options, + }) } -const iterations = 100 -const warmup = 10 - -await benchmarkFengari(iterations, warmup) -await benchmarkWasmoon(iterations, warmup) +if (isMainModule(import.meta.url)) { + await runComparisonBench(parseBenchOptions()) +} diff --git a/bench/index.js b/bench/index.js new file mode 100644 index 0000000..f2555a9 --- /dev/null +++ b/bench/index.js @@ -0,0 +1,20 @@ +import { runComparisonBench } from './comparisons.js' +import { runStepBench } from './steps.js' +import { parseBenchOptions, printArtifactSizes, printBenchUsage } from './utils.js' + +const options = parseBenchOptions() + +if (options.help) { + printBenchUsage() + process.exit(0) +} + +printArtifactSizes() + +if (options.suite === 'all' || options.suite === 'steps') { + await runStepBench(options) +} + +if (options.suite === 'all' || options.suite === 'comparisons') { + await runComparisonBench(options) +} diff --git a/bench/steps.js b/bench/steps.js index ec6c273..568a4f6 100644 --- a/bench/steps.js +++ b/bench/steps.js @@ -1,133 +1,133 @@ -import { readFileSync } from 'fs' -import path from 'path' import { Lua } from '../dist/index.js' -import assert from 'node:assert' +import assert from 'node:assert/strict' +import { isMainModule, parseBenchOptions, readBenchAsset, runBenchmarks } from './utils.js' -const heapsort = readFileSync(path.resolve(import.meta.dirname, 'heapsort.lua'), 'utf-8') - -const createFactory = async () => { - console.time('Create factory') - await Lua.load() - console.timeEnd('Create factory') -} - -const createState = async () => { - const lua = await Lua.load() - - console.time('Create state') - lua.createState() - console.timeEnd('Create state') -} - -const createStateWithoutSuperpowers = async () => { - const lua = await Lua.load() - - console.time('Create state without superpowers') - lua.createState({ - injectObjects: false, - enableProxy: false, - openStandardLibs: false, - }) - console.timeEnd('Create state without superpowers') -} - -const runHeapsort = async () => { - const lua = await Lua.load() - const state = lua.createState() - - console.time('Run plain heapsort') - state.global.lua.luaL_loadstring(state.global.address, heapsort) - state.global.lua.lua_pcallk(state.global.address, 0, 1, 0, 0, null) - state.global.lua.lua_pcallk(state.global.address, 0, 0, 0, 0, null) - console.timeEnd('Run plain heapsort') -} - -const runInteropedHeapsort = async () => { - const lua = await Lua.load() - const state = lua.createState() - - console.time('Run interoped heapsort') - const executeHeapsort = await state.doString(heapsort) - assert(executeHeapsort() === 10) - console.timeEnd('Run interoped heapsort') -} - -const insertComplexObjects = async () => { - const lua = await Lua.load() - const state = lua.createState() - const obj1 = { - hello: 'world', +const heapsort = readBenchAsset('heapsort.lua') +const luaObjectFixture = ` + local obj1 = { + hello = 'world', } obj1.self = obj1 - const obj2 = { - hello: 'everybody', - array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], - fn: () => { + local obj2 = { + 5, + hello = 'everybody', + array = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, + fn = function() return 'hello' - }, + end, } obj2.self = obj2 + obj = { obj1, obj2 } +` - console.time('Insert complex objects') - state.global.set('obj', { obj1, obj2 }) - console.timeEnd('Insert complex objects') -} - -const insertComplexObjectsWithoutProxy = async () => { - const lua = await Lua.load() - const state = lua.createState({ - enableProxy: false, - }) +function createComplexObjects() { const obj1 = { hello: 'world', } obj1.self = obj1 + const obj2 = { hello: 'everybody', array: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], - fn: () => { - return 'hello' - }, + fn: () => 'hello', } obj2.self = obj2 - console.time('Insert complex objects without proxy') - state.global.set('obj', { obj1, obj2 }) - console.timeEnd('Insert complex objects without proxy') + return { obj1, obj2 } } -const getComplexObjects = async () => { - const lua = await Lua.load() - const state = lua.createState() - await state.doString(` - local obj1 = { - hello = 'world', +function createStateBenchmark(lua, stateOptions = {}) { + return function runCreateState() { + const state = lua.createState(stateOptions) + state.global.close() + } +} + +function createRawHeapsortBenchmark(lua) { + return function runRawHeapsort() { + const state = lua.createState() + try { + assertStatus(state.global.lua.luaL_loadstring(state.global.address, heapsort), 'Load raw heapsort') + assertStatus(state.global.lua.lua_pcallk(state.global.address, 0, 1, 0, 0, null), 'Compile raw heapsort') + assertStatus(state.global.lua.lua_pcallk(state.global.address, 0, 1, 0, 0, null), 'Execute raw heapsort') + } finally { + state.global.close() + } + } +} + +function createInteropHeapsortBenchmark(lua) { + return async function runInteropHeapsort() { + const state = lua.createState() + try { + const executeHeapsort = await state.doString(heapsort) + assert.equal(executeHeapsort(), 10) + } finally { + state.global.close() + } + } +} + +function createInsertObjectsBenchmark(lua, stateOptions = {}) { + return function runInsertObjects() { + const state = lua.createState(stateOptions) + try { + state.global.set('obj', createComplexObjects()) + } finally { + state.global.close() } - obj1.self = obj1 - local obj2 = { - 5, - hello = 'everybody', - array = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, - fn = function() - return 'hello' - end + } +} + +function createGetObjectsBenchmark(lua) { + return async function runGetObjects() { + const state = lua.createState() + try { + await state.doString(luaObjectFixture) + state.global.get('obj') + } finally { + state.global.close() } - obj2.self = obj2 - obj = { obj1, obj2 } - `) + } +} - console.time('Get complex objects') - state.global.get('obj') - console.timeEnd('Get complex objects') +function assertStatus(status, label) { + if (status !== 0) { + throw new Error(`${label} failed with status ${status}`) + } } -Promise.resolve() - .then(createFactory) - .then(createState) - .then(createStateWithoutSuperpowers) - .then(runHeapsort) - .then(runInteropedHeapsort) - .then(insertComplexObjects) - .then(insertComplexObjectsWithoutProxy) - .then(getComplexObjects) - .catch(console.error) +export async function runStepBench(options = {}) { + const lua = await Lua.load() + + return runBenchmarks({ + title: 'Operation benchmarks', + benches: [ + { name: 'Create factory', run: () => Lua.load() }, + { name: 'Create state', run: createStateBenchmark(lua) }, + { + name: 'Create state without superpowers', + run: createStateBenchmark(lua, { + enableProxy: false, + injectObjects: false, + openStandardLibs: false, + }), + }, + { name: 'Run raw heapsort', run: createRawHeapsortBenchmark(lua) }, + { name: 'Run interoped heapsort', run: createInteropHeapsortBenchmark(lua) }, + { name: 'Insert complex objects', run: createInsertObjectsBenchmark(lua) }, + { + name: 'Insert complex objects without proxy', + run: createInsertObjectsBenchmark(lua, { + enableProxy: false, + }), + }, + { name: 'Get complex objects', run: createGetObjectsBenchmark(lua) }, + ], + options, + }) +} + +if (isMainModule(import.meta.url)) { + await runStepBench(parseBenchOptions()) +} diff --git a/bench/utils.js b/bench/utils.js new file mode 100644 index 0000000..798334c --- /dev/null +++ b/bench/utils.js @@ -0,0 +1,240 @@ +import { readFileSync, statSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { pathToFileURL } from 'node:url' + +const DEFAULT_OPTIONS = Object.freeze({ + filter: undefined, + help: false, + iterations: 50, + suite: 'all', + warmup: 5, +}) + +export function isMainModule(metaUrl) { + if (!process.argv[1]) { + return false + } + + return pathToFileURL(path.resolve(process.argv[1])).href === metaUrl +} + +export function readBenchAsset(fileName) { + return readFileSync(path.resolve(import.meta.dirname, fileName), 'utf-8') +} + +export function parseBenchOptions(argv = process.argv.slice(2), overrides = {}) { + const options = { ...DEFAULT_OPTIONS, ...overrides } + + for (let index = 0; index < argv.length; index++) { + const arg = argv[index] + + switch (arg) { + case '--iterations': + case '-i': + options.iterations = parseIntegerOption(argv[++index], arg, { min: 1 }) + break + case '--warmup': + case '-w': + options.warmup = parseIntegerOption(argv[++index], arg, { min: 0 }) + break + case '--filter': + case '-f': + options.filter = parseStringOption(argv[++index], arg) + break + case '--suite': + case '-s': + options.suite = parseSuiteOption(argv[++index], arg) + break + case '--help': + case '-h': + options.help = true + break + default: + throw new Error(`Unknown benchmark option: ${arg}`) + } + } + + return options +} + +export function printBenchUsage() { + console.log(`Usage: npm run bench -- [options] + +Options: + -i, --iterations Measured iterations per benchmark (default: ${DEFAULT_OPTIONS.iterations}) + -w, --warmup Warmup iterations per benchmark (default: ${DEFAULT_OPTIONS.warmup}) + -s, --suite Which suite to run: all, steps, comparisons + -f, --filter Only run benchmarks whose name includes the given text + -h, --help Show this help message`) +} + +export function printArtifactSizes() { + const artifacts = [ + { label: 'glue.js', path: path.resolve(import.meta.dirname, '../build/glue.js') }, + { label: 'glue.wasm', path: path.resolve(import.meta.dirname, '../build/glue.wasm') }, + ] + + console.log('Artifacts') + for (const artifact of artifacts) { + const size = readFileSize(artifact.path) + if (size === undefined) { + console.log(`${artifact.label}: missing (${artifact.path})`) + continue + } + + console.log(`${artifact.label}: ${formatBytes(size)} (${size} bytes)`) + } +} + +export async function runBenchmarks({ title, benches, options }) { + const activeBenches = filterBenches(benches, options.filter) + if (activeBenches.length === 0) { + throw new Error(`No benchmarks matched filter "${options.filter}"`) + } + + console.log(`\n${title}`) + console.log(`iterations=${options.iterations} warmup=${options.warmup}`) + + const results = [] + for (const bench of activeBenches) { + console.log(`running ${bench.name}...`) + results.push({ + name: bench.name, + stats: await benchmark(bench.run, options), + }) + } + + printResults(results) + return results +} + +async function benchmark(run, options) { + for (let iteration = 0; iteration < options.warmup; iteration++) { + await run() + } + + const samples = [] + for (let iteration = 0; iteration < options.iterations; iteration++) { + const start = performance.now() + await run() + samples.push(performance.now() - start) + } + + return calculateStats(samples) +} + +function calculateStats(samples) { + const sortedSamples = [...samples].sort((left, right) => left - right) + const total = samples.reduce((sum, sample) => sum + sample, 0) + const average = total / samples.length + const variance = samples.reduce((sum, sample) => sum + (sample - average) ** 2, 0) / samples.length + + return { + average, + max: sortedSamples.at(-1), + median: percentile(sortedSamples, 0.5), + min: sortedSamples[0], + stdDev: Math.sqrt(variance), + } +} + +function percentile(sortedSamples, fraction) { + const index = (sortedSamples.length - 1) * fraction + const lowerIndex = Math.floor(index) + const upperIndex = Math.ceil(index) + const lower = sortedSamples[lowerIndex] + const upper = sortedSamples[upperIndex] + + if (lowerIndex === upperIndex) { + return lower + } + + return lower + (upper - lower) * (index - lowerIndex) +} + +function printResults(results) { + const fastestAverage = Math.min(...results.map((result) => result.stats.average)) + const rows = results.map((result) => [ + result.name, + formatMilliseconds(result.stats.average), + formatMilliseconds(result.stats.median), + formatMilliseconds(result.stats.min), + formatMilliseconds(result.stats.max), + formatMilliseconds(result.stats.stdDev), + `${(result.stats.average / fastestAverage).toFixed(2)}x`, + ]) + + const headers = ['benchmark', 'avg', 'median', 'min', 'max', 'stddev', 'relative'] + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...rows.map((row) => row[columnIndex].length)), + ) + + console.log('') + console.log(formatRow(headers, widths)) + console.log(formatRow(widths.map((width) => '-'.repeat(width)), widths)) + for (const row of rows) { + console.log(formatRow(row, widths)) + } +} + +function formatRow(columns, widths) { + return columns.map((column, index) => column.padEnd(widths[index])).join(' ') +} + +function formatMilliseconds(value) { + return `${value.toFixed(3)} ms` +} + +function formatBytes(bytes) { + const units = ['B', 'KB', 'MB', 'GB'] + let size = bytes + let unitIndex = 0 + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024 + unitIndex++ + } + + return `${size.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}` +} + +function filterBenches(benches, filter) { + if (!filter) { + return benches + } + + const normalizedFilter = filter.toLowerCase() + return benches.filter((bench) => bench.name.toLowerCase().includes(normalizedFilter)) +} + +function parseIntegerOption(rawValue, flagName, { min }) { + const value = Number.parseInt(parseStringOption(rawValue, flagName), 10) + if (!Number.isInteger(value) || value < min) { + throw new Error(`${flagName} must be an integer greater than or equal to ${min}`) + } + return value +} + +function parseSuiteOption(rawValue, flagName) { + const value = parseStringOption(rawValue, flagName) + if (!['all', 'comparisons', 'steps'].includes(value)) { + throw new Error(`${flagName} must be one of: all, comparisons, steps`) + } + return value +} + +function parseStringOption(rawValue, flagName) { + if (rawValue === undefined) { + throw new Error(`Missing value for ${flagName}`) + } + return rawValue +} + +function readFileSize(filePath) { + try { + return statSync(filePath).size + } catch { + return undefined + } +} diff --git a/package-lock.json b/package-lock.json index f6f2eb7..19406af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,6 @@ "oxfmt": "0.40.0", "oxlint": "1.55.0", "rolldown": "1.0.0-rc.9", - "rollup-plugin-copy": "3.5.0", "tslib": "2.8.1", "typescript": "5.9.3" } @@ -97,44 +96,6 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@oxc-project/types": { "version": "0.115.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", @@ -1081,34 +1042,6 @@ "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", "license": "MIT" }, - "node_modules/@types/fs-extra": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", - "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/minimatch": "*", - "@types/node": "*" - } - }, - "node_modules/@types/minimatch": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", @@ -1155,16 +1088,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1182,19 +1105,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browser-stdout": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", @@ -1380,20 +1290,6 @@ "dev": true, "license": "MIT" }, - "node_modules/colorette": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", - "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1450,19 +1346,6 @@ "node": ">=0.3.1" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1500,46 +1383,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fastq": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", - "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fengari": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/fengari/-/fengari-0.1.5.tgz", @@ -1552,19 +1395,6 @@ "tmp": "^0.2.5" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1609,28 +1439,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1662,79 +1470,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/globby": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.1.tgz", - "integrity": "sha512-sSs4inE1FB2YQiymcmTv6NWENryABjUNPeWhOvmn4SjtKybglsyPZxFB3U1/+L1bYi0rNZDqCLlHyLYDl1Pq5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/glob": "^7.1.1", - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.0.3", - "glob": "^7.1.3", - "ignore": "^5.1.1", - "merge2": "^1.2.3", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/globby/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/globby/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globby/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1755,45 +1490,6 @@ "he": "bin/he" } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1804,29 +1500,6 @@ "node": ">=8" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -1847,16 +1520,6 @@ "node": ">=8" } }, - "node_modules/is-plain-object": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz", - "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -1906,16 +1569,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -1956,43 +1609,6 @@ "dev": true, "license": "ISC" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -2079,16 +1695,6 @@ "dev": true, "license": "MIT" }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/oxfmt": { "version": "0.40.0", "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.40.0.tgz", @@ -2223,16 +1829,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2260,16 +1856,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2277,27 +1863,6 @@ "dev": true, "license": "ISC" }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -2342,17 +1907,6 @@ "node": ">=0.10.0" } }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rolldown": { "version": "1.0.0-rc.9", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", @@ -2387,47 +1941,6 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" } }, - "node_modules/rollup-plugin-copy": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-copy/-/rollup-plugin-copy-3.5.0.tgz", - "integrity": "sha512-wI8D5dvYovRMx/YYKtUNt3Yxaw4ORC9xo6Gt9t22kveWz1enG9QrhVlagzwrxSC455xD1dHMKhIJkbsQ7d48BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/fs-extra": "^8.0.1", - "colorette": "^1.1.0", - "fs-extra": "^8.1.0", - "globby": "10.0.1", - "is-plain-object": "^3.0.0" - }, - "engines": { - "node": ">=8.3" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2495,16 +2008,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -2662,19 +2165,6 @@ "node": ">=14.14" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2688,7 +2178,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2704,16 +2193,6 @@ "dev": true, "license": "MIT" }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2832,13 +2311,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 3afca7c..a04eb57 100755 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build:wasm:dev": "node utils/build-wasm dev", "build:wasm": "node utils/build-wasm", "start": "rolldown -w -c", + "bench": "npm run build && node bench/index.js", "test": "mocha --parallel --require ./test/boot.js test/*.test.js", "luatests": "node --experimental-import-meta-resolve test/luatests.js", "build": "rolldown -c && tsc -d --emitDeclarationOnly --rootDir src --declarationDir dist", @@ -48,7 +49,6 @@ "oxfmt": "0.40.0", "oxlint": "1.55.0", "rolldown": "1.0.0-rc.9", - "rollup-plugin-copy": "3.5.0", "tslib": "2.8.1", "typescript": "5.9.3" }, diff --git a/rolldown.config.ts b/rolldown.config.ts index 5186a59..4446d35 100644 --- a/rolldown.config.ts +++ b/rolldown.config.ts @@ -1,5 +1,6 @@ +import { copyFile } from 'node:fs/promises' import { defineConfig } from 'rolldown' -import copy from 'rollup-plugin-copy' +import { replacePlugin } from 'rolldown/plugins' import pkg from './package.json' with { type: 'json' } export default defineConfig({ @@ -9,12 +10,12 @@ export default defineConfig({ format: 'esm', sourcemap: true, }, - external: ['module', 'node:fs', 'node:child_process'], - define: { - // Webpack workaround: https://github.com/webpack/webpack/issues/16878 - 'import.meta': 'Object(import.meta)', - }, + external: ['node:module', 'node:fs', 'node:child_process'], plugins: [ + replacePlugin({ + // Webpack workaround: https://github.com/webpack/webpack/issues/16878 + 'import.meta': 'Object(import.meta)', + }), { name: 'package-version', resolveId(source) { @@ -28,8 +29,11 @@ export default defineConfig({ } }, }, - copy({ - targets: [{ src: 'build/glue.wasm', dest: 'dist' }], - }), + { + name: 'copy-glue-wasm', + async writeBundle() { + await copyFile('build/glue.wasm', 'dist/glue.wasm') + }, + }, ], }) diff --git a/utils/build-wasm.sh b/utils/build-wasm.sh index b044209..f64546f 100755 --- a/utils/build-wasm.sh +++ b/utils/build-wasm.sh @@ -9,7 +9,7 @@ if [ "$1" == "dev" ]; then extension="-O0 -g3 -s ASSERTIONS=1 -s SAFE_HEAP=1 -s STACK_OVERFLOW_CHECK=2" else - extension="-O3" + extension="-Oz -fno-inline-functions -s BINARYEN_EXTRA_PASSES=gufa-optimizing" fi emcc \ From c3268919b999975788aef89daec84f4982f71aee Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Sat, 14 Mar 2026 14:38:19 -0300 Subject: [PATCH 32/52] lint and readme benchmark --- .github/workflows/publish.yml | 16 ++++++++-------- README.md | 17 +++++++++-------- bench/utils.js | 11 +++++++---- src/global.ts | 18 ++++-------------- 4 files changed, 28 insertions(+), 34 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3dbc317..b711daf 100755 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,19 +9,19 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: recursive - name: Setup EMSDK uses: mymindstorm/setup-emsdk@v14 with: - version: 4.0.1 + version: 5.0.2 - - name: Use Node.js 22.x - uses: actions/setup-node@v4 + - name: Use Node.js 24.x + uses: actions/setup-node@v6 with: - node-version: 22.x + node-version: 24.x - name: Install dependencies run: npm ci @@ -42,7 +42,7 @@ jobs: run: npm run luatests - name: Upload build artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: build-artifact path: | @@ -57,10 +57,10 @@ jobs: needs: build steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Download build artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: build-artifact path: build-artifact diff --git a/README.md b/README.md index a0f63e9..9d42ebd 100644 --- a/README.md +++ b/README.md @@ -74,17 +74,18 @@ $: ./sum.lua 10 30 ## When to use wasmoon and fengari -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. +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), which is an entire Lua VM rewritten in JS. ### Performance -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. +Because of WebAssembly, wasmoon runs Lua code significantly faster than fengari. The table below shows results from a [heap sort benchmark](https://github.com/ceifa/wasmoon/blob/main/bench/heapsort.lua) sorting a list of 2,000 numbers (100 iterations, 5 warmup): -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): +| | avg | median | min | max | stddev | relative | +| ---------------- | ---------- | ---------- | ---------- | ---------- | --------- | -------- | +| **Wasmoon** | 13.41 ms | 13.07 ms | 12.20 ms | 16.23 ms | 1.12 ms | 1.00x | +| **Fengari** | 137.36 ms | 138.51 ms | 119.70 ms | 165.54 ms | 11.16 ms | 10.24x | -| wasmoon | fengari | -| -------- | --------- | -| 15.267ms | 389.923ms | +Wasmoon is **~10x faster** than fengari for pure Lua execution. If your use case involves heavy interop between JS and Lua, the difference may be smaller, benchmark your specific scenario. ### Size @@ -92,8 +93,8 @@ Fengari is smaller than wasmoon, which can improve the user experience if in web | | wasmoon | fengari | | ----------- | ------- | ------- | -| **plain** | 393kB | 214kB | -| **gzipped** | 130kB | 69kB | +| **plain** | 357kB | 211kB | +| **gzipped** | 123kB | 69kB | ## Fixing common errors on web environment diff --git a/bench/utils.js b/bench/utils.js index 798334c..4c6c757 100644 --- a/bench/utils.js +++ b/bench/utils.js @@ -166,13 +166,16 @@ function printResults(results) { ]) const headers = ['benchmark', 'avg', 'median', 'min', 'max', 'stddev', 'relative'] - const widths = headers.map((header, columnIndex) => - Math.max(header.length, ...rows.map((row) => row[columnIndex].length)), - ) + const widths = headers.map((header, columnIndex) => Math.max(header.length, ...rows.map((row) => row[columnIndex].length))) console.log('') console.log(formatRow(headers, widths)) - console.log(formatRow(widths.map((width) => '-'.repeat(width)), widths)) + console.log( + formatRow( + widths.map((width) => '-'.repeat(width)), + widths, + ), + ) for (const row of rows) { console.log(formatRow(row, widths)) } diff --git a/src/global.ts b/src/global.ts index e5df71d..428aed5 100755 --- a/src/global.ts +++ b/src/global.ts @@ -36,11 +36,7 @@ export default class Global extends Thread { const endMemoryDelta = pointer ? newSize - oldSize : newSize const endMemory = memoryStats.memoryUsed + endMemoryDelta - if ( - newSize > oldSize && - memoryStats.memoryMax && - endMemory > memoryStats.memoryMax - ) { + if (newSize > oldSize && memoryStats.memoryMax && endMemory > memoryStats.memoryMax) { return 0 } @@ -179,17 +175,13 @@ export default class Global extends Thread { const type = this.lua.lua_getglobal(this.address, name) try { if (type !== LuaType.Table) { - throw new TypeError( - `Unexpected type in ${name}. Expected ${LuaType[LuaType.Table]}. Got ${LuaType[type]}.`, - ) + throw new TypeError(`Unexpected type in ${name}. Expected ${LuaType[LuaType.Table]}. Got ${LuaType[type]}.`) } callback(startStackTop + 1) } finally { // +1 for the table if (this.getTop() !== startStackTop + 1) { - console.warn( - `getTable: expected stack size ${startStackTop + 1} got ${this.getTop()}`, - ) + console.warn(`getTable: expected stack size ${startStackTop + 1} got ${this.getTop()}`) } this.setTop(startStackTop) } @@ -221,9 +213,7 @@ export default class Global extends Thread { private getMemoryStatsRef(): LuaMemoryStats { if (!this.memoryStats) { - throw new Error( - 'Memory allocations is not being traced, please build engine with { traceAllocations: true }', - ) + throw new Error('Memory allocations is not being traced, please build engine with { traceAllocations: true }') } return this.memoryStats From 4d8c2d4c455f4f6cf24ce4a21d2e9c6aaa337522 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Tue, 24 Mar 2026 21:35:22 -0300 Subject: [PATCH 33/52] nodefs --- .github/workflows/publish.yml | 2 +- .github/workflows/test.yml | 2 + package-lock.json | 532 ++++++++++++++++++---------------- package.json | 9 +- src/module.ts | 332 ++++++++++++++++----- test/browser.test.js | 200 +++++++++++++ test/node.test.js | 250 ++++++++++++++++ test/nodefs.test.js | 410 ++++++++++++++++++++++++++ 8 files changed, 1424 insertions(+), 313 deletions(-) create mode 100644 test/browser.test.js create mode 100644 test/node.test.js create mode 100644 test/nodefs.test.js diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b711daf..510a043 100755 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,7 @@ jobs: - name: Setup EMSDK uses: mymindstorm/setup-emsdk@v14 with: - version: 5.0.2 + version: 5.0.4 - name: Use Node.js 24.x uses: actions/setup-node@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e18e2c3..54fedfb 100755 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,8 @@ jobs: with: submodules: recursive - uses: mymindstorm/setup-emsdk@v14 + with: + version: 5.0.4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 with: diff --git a/package-lock.json b/package-lock.json index 19406af..1c200d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,17 +20,18 @@ "chai-as-promised": "8.0.2", "fengari": "0.1.5", "mocha": "11.7.5", - "oxfmt": "0.40.0", - "oxlint": "1.55.0", - "rolldown": "1.0.0-rc.9", + "oxfmt": "0.42.0", + "oxlint": "1.57.0", + "playwright": "1.58.2", + "rolldown": "1.0.0-rc.11", "tslib": "2.8.1", - "typescript": "5.9.3" + "typescript": "6.0.2" } }, "node_modules/@emnapi/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", "dev": true, "license": "MIT", "optional": true, @@ -40,9 +41,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", "dev": true, "license": "MIT", "optional": true, @@ -97,9 +98,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", - "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", "dev": true, "license": "MIT", "funding": { @@ -107,9 +108,9 @@ } }, "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.40.0.tgz", - "integrity": "sha512-S6zd5r1w/HmqR8t0CTnGjFTBLDq2QKORPwriCHxo4xFNuhmOTABGjPaNvCJJVnrKBLsohOeiDX3YqQfJPF+FXw==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.42.0.tgz", + "integrity": "sha512-dsqPTYsozeokRjlrt/b4E7Pj0z3eS3Eg74TWQuuKbjY4VttBmA88rB7d50Xrd+TZ986qdXCNeZRPEzZHAe+jow==", "cpu": [ "arm" ], @@ -124,9 +125,9 @@ } }, "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.40.0.tgz", - "integrity": "sha512-/mbS9UUP/5Vbl2D6osIdcYiP0oie63LKMoTyGj5hyMCK/SFkl3EhtyRAfdjPvuvHC0SXdW6ePaTKkBSq1SNcIw==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.42.0.tgz", + "integrity": "sha512-t+aAjHxcr5eOBphFHdg1ouQU9qmZZoRxnX7UOJSaTwSoKsb6TYezNKO0YbWytGXCECObRqNcUxPoPr0KaraAIg==", "cpu": [ "arm64" ], @@ -141,9 +142,9 @@ } }, "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.40.0.tgz", - "integrity": "sha512-wRt8fRdfLiEhnRMBonlIbKrJWixoEmn6KCjKE9PElnrSDSXETGZfPb8ee+nQNTobXkCVvVLytp2o0obAsxl78Q==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.42.0.tgz", + "integrity": "sha512-ulpSEYMKg61C5bRMZinFHrKJYRoKGVbvMEXA5zM1puX3O9T6Q4XXDbft20yrDijpYWeuG59z3Nabt+npeTsM1A==", "cpu": [ "arm64" ], @@ -158,9 +159,9 @@ } }, "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.40.0.tgz", - "integrity": "sha512-fzowhqbOE/NRy+AE5ob0+Y4X243WbWzDb00W+pKwD7d9tOqsAFbtWUwIyqqCoCLxj791m2xXIEeLH/3uz7zCCg==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.42.0.tgz", + "integrity": "sha512-ttxLKhQYPdFiM8I/Ri37cvqChE4Xa562nNOsZFcv1CKTVLeEozXjKuYClNvxkXmNlcF55nzM80P+CQkdFBu+uQ==", "cpu": [ "x64" ], @@ -175,9 +176,9 @@ } }, "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.40.0.tgz", - "integrity": "sha512-agZ9ITaqdBjcerRRFEHB8s0OyVcQW8F9ZxsszjxzeSthQ4fcN2MuOtQFWec1ed8/lDa50jSLHVE2/xPmTgtCfQ==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.42.0.tgz", + "integrity": "sha512-Og7QS3yI3tdIKYZ58SXik0rADxIk2jmd+/YvuHRyKULWpG4V2fR5V4hvKm624Mc0cQET35waPXiCQWvjQEjwYQ==", "cpu": [ "x64" ], @@ -192,9 +193,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.40.0.tgz", - "integrity": "sha512-ZM2oQ47p28TP1DVIp7HL1QoMUgqlBFHey0ksHct7tMXoU5BqjNvPWw7888azzMt25lnyPODVuye1wvNbvVUFOA==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.42.0.tgz", + "integrity": "sha512-jwLOw/3CW4H6Vxcry4/buQHk7zm9Ne2YsidzTL1kpiMe4qqrRCwev3dkyWe2YkFmP+iZCQ7zku4KwjcLRoh8ew==", "cpu": [ "arm" ], @@ -209,9 +210,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.40.0.tgz", - "integrity": "sha512-RBFPAxRAIsMisKM47Oe6Lwdv6agZYLz02CUhVCD1sOv5ajAcRMrnwCFBPWwGXpazToW2mjnZxFos8TuFjTU15A==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.42.0.tgz", + "integrity": "sha512-XwXu2vkMtiq2h7tfvN+WA/9/5/1IoGAVCFPiiQUvcAuG3efR97KNcRGM8BetmbYouFotQ2bDal3yyjUx6IPsTg==", "cpu": [ "arm" ], @@ -226,9 +227,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.40.0.tgz", - "integrity": "sha512-Nb2XbQ+wV3W2jSIihXdPj7k83eOxeSgYP3N/SRXvQ6ZYPIk6Q86qEh5Gl/7OitX3bQoQrESqm1yMLvZV8/J7dA==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.42.0.tgz", + "integrity": "sha512-ea7s/XUJoT7ENAtUQDudFe3nkSM3e3Qpz4nJFRdzO2wbgXEcjnchKLEsV3+t4ev3r8nWxIYr9NRjPWtnyIFJVA==", "cpu": [ "arm64" ], @@ -243,9 +244,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.40.0.tgz", - "integrity": "sha512-tGmWhLD/0YMotCdfezlT6tC/MJG/wKpo4vnQ3Cq+4eBk/BwNv7EmkD0VkD5F/dYkT3b8FNU01X2e8vvJuWoM1w==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.42.0.tgz", + "integrity": "sha512-+JA0YMlSdDqmacygGi2REp57c3fN+tzARD8nwsukx9pkCHK+6DkbAA9ojS4lNKsiBjIW8WWa0pBrBWhdZEqfuw==", "cpu": [ "arm64" ], @@ -260,9 +261,9 @@ } }, "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.40.0.tgz", - "integrity": "sha512-rVbFyM3e7YhkVnp0IVYjaSHfrBWcTRWb60LEcdNAJcE2mbhTpbqKufx0FrhWfoxOrW/+7UJonAOShoFFLigDqQ==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.42.0.tgz", + "integrity": "sha512-VfnET0j4Y5mdfCzh5gBt0NK28lgn5DKx+8WgSMLYYeSooHhohdbzwAStLki9pNuGy51y4I7IoW8bqwAaCMiJQg==", "cpu": [ "ppc64" ], @@ -277,9 +278,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.40.0.tgz", - "integrity": "sha512-3ZqBw14JtWeEoLiioJcXSJz8RQyPE+3jLARnYM1HdPzZG4vk+Ua8CUupt2+d+vSAvMyaQBTN2dZK+kbBS/j5mA==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.42.0.tgz", + "integrity": "sha512-gVlCbmBkB0fxBWbhBj9rcxezPydsQHf4MFKeHoTSPicOQ+8oGeTQgQ8EeesSybWeiFPVRx3bgdt4IJnH6nOjAA==", "cpu": [ "riscv64" ], @@ -294,9 +295,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.40.0.tgz", - "integrity": "sha512-JJ4PPSdcbGBjPvb+O7xYm2FmAsKCyuEMYhqatBAHMp/6TA6rVlf9Z/sYPa4/3Bommb+8nndm15SPFRHEPU5qFA==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.42.0.tgz", + "integrity": "sha512-zN5OfstL0avgt/IgvRu0zjQzVh/EPkcLzs33E9LMAzpqlLWiPWeMDZyMGFlSRGOdDjuNmlZBCgj0pFnK5u32TQ==", "cpu": [ "riscv64" ], @@ -311,9 +312,9 @@ } }, "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.40.0.tgz", - "integrity": "sha512-Kp0zNJoX9Ik77wUya2tpBY3W9f40VUoMQLWVaob5SgCrblH/t2xr/9B2bWHfs0WCefuGmqXcB+t0Lq77sbBmZw==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.42.0.tgz", + "integrity": "sha512-9X6+H2L0qMc2sCAgO9HS03bkGLMKvOFjmEdchaFlany3vNZOjnVui//D8k/xZAtQv2vaCs1reD5KAgPoIU4msA==", "cpu": [ "s390x" ], @@ -328,9 +329,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.40.0.tgz", - "integrity": "sha512-7YTCNzleWTaQTqNGUNQ66qVjpoV6DjbCOea+RnpMBly2bpzrI/uu7Rr+2zcgRfNxyjXaFTVQKaRKjqVdeUfeVA==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.42.0.tgz", + "integrity": "sha512-BajxJ6KQvMMdpXGPWhBGyjb2Jvx4uec0w+wi6TJZ6Tv7+MzPwe0pO8g5h1U0jyFgoaF7mDl6yKPW3ykWcbUJRw==", "cpu": [ "x64" ], @@ -345,9 +346,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.40.0.tgz", - "integrity": "sha512-hWnSzJ0oegeOwfOEeejYXfBqmnRGHusgtHfCPzmvJvHTwy1s3Neo59UKc1CmpE3zxvrCzJoVHos0rr97GHMNPw==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.42.0.tgz", + "integrity": "sha512-0wV284I6vc5f0AqAhgAbHU2935B4bVpncPoe5n/WzVZY/KnHgqxC8iSFGeSyLWEgstFboIcWkOPck7tqbdHkzA==", "cpu": [ "x64" ], @@ -362,9 +363,9 @@ } }, "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.40.0.tgz", - "integrity": "sha512-28sJC1lR4qtBJGzSRRbPnSW3GxU2+4YyQFE6rCmsUYqZ5XYH8jg0/w+CvEzQ8TuAQz5zLkcA25nFQGwoU0PT3Q==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.42.0.tgz", + "integrity": "sha512-p4BG6HpGnhfgHk1rzZfyR6zcWkE7iLrWxyehHfXUy4Qa5j3e0roglFOdP/Nj5cJJ58MA3isQ5dlfkW2nNEpolw==", "cpu": [ "arm64" ], @@ -379,9 +380,9 @@ } }, "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.40.0.tgz", - "integrity": "sha512-cDkRnyT0dqwF5oIX1Cv59HKCeZQFbWWdUpXa3uvnHFT2iwYSSZspkhgjXjU6iDp5pFPaAEAe9FIbMoTgkTmKPg==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.42.0.tgz", + "integrity": "sha512-mn//WV60A+IetORDxYieYGAoQso4KnVRRjORDewMcod4irlRe0OSC7YPhhwaexYNPQz/GCFk+v9iUcZ2W22yxQ==", "cpu": [ "arm64" ], @@ -396,9 +397,9 @@ } }, "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.40.0.tgz", - "integrity": "sha512-7rPemBJjqm5Gkv6ZRCPvK8lE6AqQ/2z31DRdWazyx2ZvaSgL7QGofHXHNouRpPvNsT9yxRNQJgigsWkc+0qg4w==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.42.0.tgz", + "integrity": "sha512-3gWltUrvuz4LPJXWivoAxZ28Of2O4N7OGuM5/X3ubPXCEV8hmgECLZzjz7UYvSDUS3grfdccQwmjynm+51EFpw==", "cpu": [ "ia32" ], @@ -413,9 +414,9 @@ } }, "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.40.0.tgz", - "integrity": "sha512-/Zmj0yTYSvmha6TG1QnoLqVT7ZMRDqXvFXXBQpIjteEwx9qvUYMBH2xbiOFhDeMUJkGwC3D6fdKsFtaqUvkwNA==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.42.0.tgz", + "integrity": "sha512-Wg4TMAfQRL9J9AZevJ/ZNy3uyyDztDYQtGr4P8UyyzIhLhFrdSmz1J/9JT+rv0fiCDLaFOBQnj3f3K3+a5PzDQ==", "cpu": [ "x64" ], @@ -430,9 +431,9 @@ } }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.55.0.tgz", - "integrity": "sha512-NhvgAhncTSOhRahQSCnkK/4YIGPjTmhPurQQ2dwt2IvwCMTvZRW5vF2K10UBOxFve4GZDMw6LtXZdC2qeuYIVQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.57.0.tgz", + "integrity": "sha512-C7EiyfAJG4B70496eV543nKiq5cH0o/xIh/ufbjQz3SIvHhlDDsyn+mRFh+aW8KskTyUpyH2LGWL8p2oN6bl1A==", "cpu": [ "arm" ], @@ -447,9 +448,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.55.0.tgz", - "integrity": "sha512-P9iWRh+Ugqhg+D7rkc7boHX8o3H2h7YPcZHQIgvVBgnua5tk4LR2L+IBlreZs58/95cd2x3/004p5VsQM9z4SA==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.57.0.tgz", + "integrity": "sha512-9i80AresjZ/FZf5xK8tKFbhQnijD4s1eOZw6/FHUwD59HEZbVLRc2C88ADYJfLZrF5XofWDiRX/Ja9KefCLy7w==", "cpu": [ "arm64" ], @@ -464,9 +465,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.55.0.tgz", - "integrity": "sha512-esakkJIt7WFAhT30P/Qzn96ehFpzdZ1mNuzpOb8SCW7lI4oB8VsyQnkSHREM671jfpuBb/o2ppzBCx5l0jpgMA==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.57.0.tgz", + "integrity": "sha512-0eUfhRz5L2yKa9I8k3qpyl37XK3oBS5BvrgdVIx599WZK63P8sMbg+0s4IuxmIiZuBK68Ek+Z+gcKgeYf0otsg==", "cpu": [ "arm64" ], @@ -481,9 +482,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.55.0.tgz", - "integrity": "sha512-xDMFRCCAEK9fOH6As2z8ELsC+VDGSFRHwIKVSilw+xhgLwTDFu37rtmRbmUlx8rRGS6cWKQPTc47AVxAZEVVPQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.57.0.tgz", + "integrity": "sha512-UvrSuzBaYOue+QMAcuDITe0k/Vhj6KZGjfnI6x+NkxBTke/VoM7ZisaxgNY0LWuBkTnd1OmeQfEQdQ48fRjkQg==", "cpu": [ "x64" ], @@ -498,9 +499,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.55.0.tgz", - "integrity": "sha512-mYZqnwUD7ALCRxGenyLd1uuG+rHCL+OTT6S8FcAbVm/ZT2AZMGjvibp3F6k1SKOb2aeqFATmwRykrE41Q0GWVw==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.57.0.tgz", + "integrity": "sha512-wtQq0dCoiw4bUwlsNVDJJ3pxJA218fOezpgtLKrbQqUtQJcM9yP8z+I9fu14aHg0uyAxIY+99toL6uBa2r7nxA==", "cpu": [ "x64" ], @@ -515,9 +516,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.55.0.tgz", - "integrity": "sha512-LcX6RYcF9vL9ESGwJW3yyIZ/d/ouzdOKXxCdey1q0XJOW1asrHsIg5MmyKdEBR4plQx+shvYeQne7AzW5f3T1w==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.57.0.tgz", + "integrity": "sha512-qxFWl2BBBFcT4djKa+OtMdnLgoHEJXpqjyGwz8OhW35ImoCwR5qtAGqApNYce5260FQqoAHW8S8eZTjiX67Tsg==", "cpu": [ "arm" ], @@ -532,9 +533,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.55.0.tgz", - "integrity": "sha512-C+8GS1rPtK+dI7mJFkqoRBkDuqbrNihnyYQsJPS9ez+8zF9JzfvU19lawqt4l/Y23o5uQswE/DORa8aiXUih3w==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.57.0.tgz", + "integrity": "sha512-SQoIsBU7J0bDW15/f0/RvxHfY3Y0+eB/caKBQtNFbuerTiA6JCYx9P1MrrFTwY2dTm/lMgTSgskvCEYk2AtG/Q==", "cpu": [ "arm" ], @@ -549,9 +550,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.55.0.tgz", - "integrity": "sha512-ErLE4XbmcCopA4/CIDiH6J1IAaDOMnf/KSx/aFObs4/OjAAM3sFKWGZ57pNOMxhhyBdcmcXwYymph9GwcpcqgQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.57.0.tgz", + "integrity": "sha512-jqxYd1W6WMeozsCmqe9Rzbu3SRrGTyGDAipRlRggetyYbUksJqJKvUNTQtZR/KFoJPb+grnSm5SHhdWrywv3RQ==", "cpu": [ "arm64" ], @@ -566,9 +567,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.55.0.tgz", - "integrity": "sha512-/kp65avi6zZfqEng56TTuhiy3P/3pgklKIdf38yvYeJ9/PgEeRA2A2AqKAKbZBNAqUzrzHhz9jF6j/PZvhJzTQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.57.0.tgz", + "integrity": "sha512-i66WyEPVEvq9bxRUCJ/MP5EBfnTDN3nhwEdFZFTO5MmLLvzngfWEG3NSdXQzTT3vk5B9i6C2XSIYBh+aG6uqyg==", "cpu": [ "arm64" ], @@ -583,9 +584,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.55.0.tgz", - "integrity": "sha512-A6pTdXwcEEwL/nmz0eUJ6WxmxcoIS+97GbH96gikAyre3s5deC7sts38ZVVowjS2QQFuSWkpA4ZmQC0jZSNvJQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.57.0.tgz", + "integrity": "sha512-oMZDCwz4NobclZU3pH+V1/upVlJZiZvne4jQP+zhJwt+lmio4XXr4qG47CehvrW1Lx2YZiIHuxM2D4YpkG3KVA==", "cpu": [ "ppc64" ], @@ -600,9 +601,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.55.0.tgz", - "integrity": "sha512-clj0lnIN+V52G9tdtZl0LbdTSurnZ1NZj92Je5X4lC7gP5jiCSW+Y/oiDiSauBAD4wrHt2S7nN3pA0zfKYK/6Q==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.57.0.tgz", + "integrity": "sha512-uoBnjJ3MMEBbfnWC1jSFr7/nSCkcQYa72NYoNtLl1imshDnWSolYCjzb8LVCwYCCfLJXD+0gBLD7fyC14c0+0g==", "cpu": [ "riscv64" ], @@ -617,9 +618,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.55.0.tgz", - "integrity": "sha512-NNu08pllN5x/O94/sgR3DA8lbrGBnTHsINZZR0hcav1sj79ksTiKKm1mRzvZvacwQ0hUnGinFo+JO75ok2PxYg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.57.0.tgz", + "integrity": "sha512-BdrwD7haPZ8a9KrZhKJRSj6jwCor+Z8tHFZ3PT89Y3Jq5v3LfMfEePeAmD0LOTWpiTmzSzdmyw9ijneapiVHKQ==", "cpu": [ "riscv64" ], @@ -634,9 +635,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.55.0.tgz", - "integrity": "sha512-BvfQz3PRlWZRoEZ17dZCqgQsMRdpzGZomJkVATwCIGhHVVeHJMQdmdXPSjcT1DCNUrOjXnVyj1RGDj5+/Je2+Q==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.57.0.tgz", + "integrity": "sha512-BNs+7ZNsRstVg2tpNxAXfMX/Iv5oZh204dVyb8Z37+/gCh+yZqNTlg6YwCLIMPSk5wLWIGOaQjT0GUOahKYImw==", "cpu": [ "s390x" ], @@ -651,9 +652,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.55.0.tgz", - "integrity": "sha512-ngSOoFCSBMKVQd24H8zkbcBNc7EHhjnF1sv3mC9NNXQ/4rRjI/4Dj9+9XoDZeFEkF1SX1COSBXF1b2Pr9rqdEw==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.57.0.tgz", + "integrity": "sha512-AghS18w+XcENcAX0+BQGLiqjpqpaxKJa4cWWP0OWNLacs27vHBxu7TYkv9LUSGe5w8lOJHeMxcYfZNOAPqw2bg==", "cpu": [ "x64" ], @@ -668,9 +669,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.55.0.tgz", - "integrity": "sha512-BDpP7W8GlaG7BR6QjGZAleYzxoyKc/D24spZIF2mB3XsfALQJJT/OBmP8YpeTb1rveFSBHzl8T7l0aqwkWNdGA==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.57.0.tgz", + "integrity": "sha512-E/FV3GB8phu/Rpkhz5T96hAiJlGzn91qX5yj5gU754P5cmVGXY1Jw/VSjDSlZBCY3VHjsVLdzgdkJaomEmcNOg==", "cpu": [ "x64" ], @@ -685,9 +686,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.55.0.tgz", - "integrity": "sha512-PS6GFvmde/pc3fCA2Srt51glr8Lcxhpf6WIBFfLphndjRrD34NEcses4TSxQrEcxYo6qVywGfylM0ZhSCF2gGA==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.57.0.tgz", + "integrity": "sha512-xvZ2yZt0nUVfU14iuGv3V25jpr9pov5N0Wr28RXnHFxHCRxNDMtYPHV61gGLhN9IlXM96gI4pyYpLSJC5ClLCQ==", "cpu": [ "arm64" ], @@ -702,9 +703,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.55.0.tgz", - "integrity": "sha512-P6JcLJGs/q1UOvDLzN8otd9JsH4tsuuPDv+p7aHqHM3PrKmYdmUvkNj4K327PTd35AYcznOCN+l4ZOaq76QzSw==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.57.0.tgz", + "integrity": "sha512-Z4D8Pd0AyHBKeazhdIXeUUy5sIS3Mo0veOlzlDECg6PhRRKgEsBJCCV1n+keUZtQ04OP+i7+itS3kOykUyNhDg==", "cpu": [ "arm64" ], @@ -719,9 +720,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.55.0.tgz", - "integrity": "sha512-gzkk4zE2zsE+WmRxFOiAZHpCpUNDFytEakqNXoNHW+PnYEOTPKDdW6nrzgSeTbGKVPXNAKQnRnMgrh7+n3Xueg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.57.0.tgz", + "integrity": "sha512-StOZ9nFMVKvevicbQfql6Pouu9pgbeQnu60Fvhz2S6yfMaii+wnueLnqQ5I1JPgNF0Syew4voBlAaHD13wH6tw==", "cpu": [ "ia32" ], @@ -736,9 +737,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.55.0.tgz", - "integrity": "sha512-ZFALNow2/og75gvYzNP7qe+rREQ5xunktwA+lgykoozHZ6hw9bqg4fn5j2UvG4gIn1FXqrZHkOAXuPf5+GOYTQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.57.0.tgz", + "integrity": "sha512-6PuxhYgth8TuW0+ABPOIkGdBYw+qYGxgIdXPHSVpiCDm+hqTTWCmC739St1Xni0DJBt8HnSHTG67i1y6gr8qrA==", "cpu": [ "x64" ], @@ -764,9 +765,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", "cpu": [ "arm64" ], @@ -781,9 +782,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", "cpu": [ "arm64" ], @@ -798,9 +799,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", "cpu": [ "x64" ], @@ -815,9 +816,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", "cpu": [ "x64" ], @@ -832,9 +833,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", - "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", + "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", "cpu": [ "arm" ], @@ -849,9 +850,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", "cpu": [ "arm64" ], @@ -866,9 +867,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", "cpu": [ "arm64" ], @@ -883,9 +884,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", "cpu": [ "ppc64" ], @@ -900,9 +901,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", "cpu": [ "s390x" ], @@ -917,9 +918,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", "cpu": [ "x64" ], @@ -934,9 +935,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", "cpu": [ "x64" ], @@ -951,9 +952,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", "cpu": [ "arm64" ], @@ -968,9 +969,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", - "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", + "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", "cpu": [ "wasm32" ], @@ -985,9 +986,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", "cpu": [ "arm64" ], @@ -1002,9 +1003,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", "cpu": [ "x64" ], @@ -1019,9 +1020,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", - "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", + "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", "dev": true, "license": "MIT" }, @@ -1439,6 +1440,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1696,9 +1712,9 @@ "license": "MIT" }, "node_modules/oxfmt": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.40.0.tgz", - "integrity": "sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.42.0.tgz", + "integrity": "sha512-QhejGErLSMReNuZ6vxgFHDyGoPbjTRNi6uGHjy0cvIjOQFqD6xmr/T+3L41ixR3NIgzcNiJ6ylQKpvShTgDfqg==", "dev": true, "license": "MIT", "dependencies": { @@ -1714,31 +1730,31 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.40.0", - "@oxfmt/binding-android-arm64": "0.40.0", - "@oxfmt/binding-darwin-arm64": "0.40.0", - "@oxfmt/binding-darwin-x64": "0.40.0", - "@oxfmt/binding-freebsd-x64": "0.40.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.40.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.40.0", - "@oxfmt/binding-linux-arm64-gnu": "0.40.0", - "@oxfmt/binding-linux-arm64-musl": "0.40.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.40.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.40.0", - "@oxfmt/binding-linux-riscv64-musl": "0.40.0", - "@oxfmt/binding-linux-s390x-gnu": "0.40.0", - "@oxfmt/binding-linux-x64-gnu": "0.40.0", - "@oxfmt/binding-linux-x64-musl": "0.40.0", - "@oxfmt/binding-openharmony-arm64": "0.40.0", - "@oxfmt/binding-win32-arm64-msvc": "0.40.0", - "@oxfmt/binding-win32-ia32-msvc": "0.40.0", - "@oxfmt/binding-win32-x64-msvc": "0.40.0" + "@oxfmt/binding-android-arm-eabi": "0.42.0", + "@oxfmt/binding-android-arm64": "0.42.0", + "@oxfmt/binding-darwin-arm64": "0.42.0", + "@oxfmt/binding-darwin-x64": "0.42.0", + "@oxfmt/binding-freebsd-x64": "0.42.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.42.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.42.0", + "@oxfmt/binding-linux-arm64-gnu": "0.42.0", + "@oxfmt/binding-linux-arm64-musl": "0.42.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.42.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.42.0", + "@oxfmt/binding-linux-riscv64-musl": "0.42.0", + "@oxfmt/binding-linux-s390x-gnu": "0.42.0", + "@oxfmt/binding-linux-x64-gnu": "0.42.0", + "@oxfmt/binding-linux-x64-musl": "0.42.0", + "@oxfmt/binding-openharmony-arm64": "0.42.0", + "@oxfmt/binding-win32-arm64-msvc": "0.42.0", + "@oxfmt/binding-win32-ia32-msvc": "0.42.0", + "@oxfmt/binding-win32-x64-msvc": "0.42.0" } }, "node_modules/oxlint": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.55.0.tgz", - "integrity": "sha512-T+FjepiyWpaZMhekqRpH8Z3I4vNM610p6w+Vjfqgj5TZUxHXl7N8N5IPvmOU8U4XdTRxqtNNTh9Y4hLtr7yvFg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.57.0.tgz", + "integrity": "sha512-DGFsuBX5MFZX9yiDdtKjTrYPq45CZ8Fft6qCltJITYZxfwYjVdGf/6wycGYTACloauwIPxUnYhBVeZbHvleGhw==", "dev": true, "license": "MIT", "bin": { @@ -1751,25 +1767,25 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.55.0", - "@oxlint/binding-android-arm64": "1.55.0", - "@oxlint/binding-darwin-arm64": "1.55.0", - "@oxlint/binding-darwin-x64": "1.55.0", - "@oxlint/binding-freebsd-x64": "1.55.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.55.0", - "@oxlint/binding-linux-arm-musleabihf": "1.55.0", - "@oxlint/binding-linux-arm64-gnu": "1.55.0", - "@oxlint/binding-linux-arm64-musl": "1.55.0", - "@oxlint/binding-linux-ppc64-gnu": "1.55.0", - "@oxlint/binding-linux-riscv64-gnu": "1.55.0", - "@oxlint/binding-linux-riscv64-musl": "1.55.0", - "@oxlint/binding-linux-s390x-gnu": "1.55.0", - "@oxlint/binding-linux-x64-gnu": "1.55.0", - "@oxlint/binding-linux-x64-musl": "1.55.0", - "@oxlint/binding-openharmony-arm64": "1.55.0", - "@oxlint/binding-win32-arm64-msvc": "1.55.0", - "@oxlint/binding-win32-ia32-msvc": "1.55.0", - "@oxlint/binding-win32-x64-msvc": "1.55.0" + "@oxlint/binding-android-arm-eabi": "1.57.0", + "@oxlint/binding-android-arm64": "1.57.0", + "@oxlint/binding-darwin-arm64": "1.57.0", + "@oxlint/binding-darwin-x64": "1.57.0", + "@oxlint/binding-freebsd-x64": "1.57.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.57.0", + "@oxlint/binding-linux-arm-musleabihf": "1.57.0", + "@oxlint/binding-linux-arm64-gnu": "1.57.0", + "@oxlint/binding-linux-arm64-musl": "1.57.0", + "@oxlint/binding-linux-ppc64-gnu": "1.57.0", + "@oxlint/binding-linux-riscv64-gnu": "1.57.0", + "@oxlint/binding-linux-riscv64-musl": "1.57.0", + "@oxlint/binding-linux-s390x-gnu": "1.57.0", + "@oxlint/binding-linux-x64-gnu": "1.57.0", + "@oxlint/binding-linux-x64-musl": "1.57.0", + "@oxlint/binding-openharmony-arm64": "1.57.0", + "@oxlint/binding-win32-arm64-msvc": "1.57.0", + "@oxlint/binding-win32-ia32-msvc": "1.57.0", + "@oxlint/binding-win32-x64-msvc": "1.57.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" @@ -1863,6 +1879,38 @@ "dev": true, "license": "ISC" }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -1908,14 +1956,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", - "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", + "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.9" + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.11" }, "bin": { "rolldown": "bin/cli.mjs" @@ -1924,21 +1972,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-x64": "1.0.0-rc.9", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + "@rolldown/binding-android-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-x64": "1.0.0-rc.11", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" } }, "node_modules/safe-buffer": { @@ -2173,9 +2221,9 @@ "license": "0BSD" }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index a04eb57..6b951a3 100755 --- a/package.json +++ b/package.json @@ -46,11 +46,12 @@ "chai-as-promised": "8.0.2", "fengari": "0.1.5", "mocha": "11.7.5", - "oxfmt": "0.40.0", - "oxlint": "1.55.0", - "rolldown": "1.0.0-rc.9", + "oxfmt": "0.42.0", + "oxlint": "1.57.0", + "rolldown": "1.0.0-rc.11", "tslib": "2.8.1", - "typescript": "5.9.3" + "typescript": "6.0.2", + "playwright": "1.58.2" }, "dependencies": { "@types/emscripten": "1.41.5" diff --git a/src/module.ts b/src/module.ts index a4bde12..d9590a0 100755 --- a/src/module.ts +++ b/src/module.ts @@ -40,6 +40,7 @@ export default class LuaModule { wasmFile?: string env?: EnvironmentVariables fs?: 'node' | 'memory' + fsMountPaths?: string[] stdin?: () => string stdout?: (content: string) => void stderr?: (content: string) => void @@ -52,8 +53,8 @@ export default class LuaModule { opts.wasmFile = `https://unpkg.com/wasmoon@${version}/dist/glue.wasm` } - const fs = !isBrowser && opts.fs === 'node' && typeof process !== 'undefined' ? await import('node:fs') : null - const child_process = !isBrowser && opts.fs === 'node' && typeof process !== 'undefined' ? await import('node:child_process') : null + const useNodeFS = !isBrowser && opts.fs === 'node' && typeof process !== 'undefined' + const fs = useNodeFS ? await import('node:fs') : null const module: LuaEmscriptenModule = await initWasmModule({ locateFile: (path: string, scriptDirectory: string) => { @@ -64,32 +65,44 @@ export default class LuaModule { Object.assign(initializedModule.ENV, opts.env) } - if (fs && child_process) { + if (fs) { let rootdirs: string[] - if (process.platform === 'win32') { - const stdout = child_process.execSync('wmic logicaldisk get name', { - encoding: 'utf8', - }) - const drives = stdout - .split('\n') - .map((line) => line.trim()) - .filter((line) => line && line !== 'Name') - .map((line) => `${line}\\`) + if (opts.fsMountPaths) { + rootdirs = opts.fsMountPaths + } else if (process.platform === 'win32') { + const drives: string[] = [] + for (let i = 65; i <= 90; i++) { + const drive = `${String.fromCharCode(i)}:\\` + try { + fs.accessSync(drive) + drives.push(drive) + } catch { + // drive does not exist + } + } rootdirs = [] for (const drive of drives) { - rootdirs.push( - ...fs - .readdirSync(drive) - .filter((dir) => !['dev', 'lib', 'proc'].includes(dir)) - .map((dir) => `${drive}${dir}`.replace(/\\|\\\\/g, '/')), - ) + try { + rootdirs.push( + ...fs + .readdirSync(drive) + .filter(dir => !['dev', 'lib', 'proc'].includes(dir)) + .map(dir => `${drive}${dir}`.replace(/\\/g, '/')), + ) + } catch { + // drive not readable + } } } else { - rootdirs = fs - .readdirSync('/') - .filter((dir) => !['dev', 'lib', 'proc'].includes(dir)) - .map((dir) => `/${dir}`) + try { + rootdirs = fs + .readdirSync('/') + .filter(dir => !['dev', 'lib', 'proc'].includes(dir)) + .map(dir => `/${dir}`) + } catch { + rootdirs = [] + } } for (const dir of rootdirs) { @@ -97,12 +110,20 @@ export default class LuaModule { const moduleFS = initializedModule.FS moduleFS.mkdirTree(dir) moduleFS.mount(moduleFS.filesystems.NODEFS, { root: dir }, dir) - } catch { - // silently fail to mount (generally due to EPERM) + } catch (err: any) { + const isPermError = + err?.code === 'EACCES' || err?.code === 'EPERM' || err?.errno === 2 + if (!isPermError) { + console.warn(`Failed to mount ${dir}:`, err) + } } } - initializedModule.FS.chdir(process.cwd().replace(/\\|\\\\/g, '/')) + try { + initializedModule.FS.chdir(process.cwd().replace(/\\/g, '/')) + } catch { + // CWD may not be within mounted paths + } } if (opts.stdin || opts.stdout || opts.stderr) { @@ -113,7 +134,9 @@ export default class LuaModule { if (!bufferedInput) { const input = opts.stdin?.() if (typeof input === 'string') { - bufferedInput = initializedModule.intArrayFromString(input, true).concat([0]) + bufferedInput = initializedModule + .intArrayFromString(input, true) + .concat([0]) } else { throw new Error('stdin must return a string') } @@ -146,7 +169,12 @@ export default class LuaModule { public luaL_argerror: (L: LuaState, arg: number, extramsg: string | null) => number public luaL_typeerror: (L: LuaState, arg: number, tname: string | null) => number public luaL_checklstring: (L: LuaState, arg: number, l: number | null) => string - public luaL_optlstring: (L: LuaState, arg: number, def: string | null, l: number | null) => string + public luaL_optlstring: ( + L: LuaState, + arg: number, + def: string | null, + l: number | null, + ) => string public luaL_checknumber: (L: LuaState, arg: number) => number public luaL_optnumber: (L: LuaState, arg: number, def: number) => number public luaL_checkinteger: (L: LuaState, arg: number) => number @@ -174,7 +202,12 @@ export default class LuaModule { public luaL_loadstring: (L: LuaState, s: string | null) => LuaReturn public luaL_newstate: () => LuaState public luaL_len: (L: LuaState, idx: number) => number - public luaL_addgsub: (b: number | null, s: string | null, p: string | null, r: string | null) => void + public luaL_addgsub: ( + b: number | null, + s: string | null, + p: string | null, + r: string | null, + ) => void public luaL_gsub: (L: LuaState, s: string | null, p: string | null, r: string | null) => string public luaL_setfuncs: (L: LuaState, l: number | null, nup: number) => void public luaL_getsubtable: (L: LuaState, idx: number, fname: string | null) => number @@ -252,12 +285,41 @@ export default class LuaModule { public lua_rawsetp: (L: LuaState, idx: number, p: number | null) => void public lua_setmetatable: (L: LuaState, objindex: number) => number public lua_setiuservalue: (L: LuaState, idx: number, n: number) => number - public lua_callk: (L: LuaState, nargs: number, nresults: number, ctx: number, k: number | null) => void - public lua_pcallk: (L: LuaState, nargs: number, nresults: number, errfunc: number, ctx: number, k: number | null) => number - public lua_load: (L: LuaState, reader: number | null, dt: number | null, chunkname: string | null, mode: string | null) => LuaReturn - public lua_dump: (L: LuaState, writer: number | null, data: number | null, strip: number) => number + public lua_callk: ( + L: LuaState, + nargs: number, + nresults: number, + ctx: number, + k: number | null, + ) => void + public lua_pcallk: ( + L: LuaState, + nargs: number, + nresults: number, + errfunc: number, + ctx: number, + k: number | null, + ) => number + public lua_load: ( + L: LuaState, + reader: number | null, + dt: number | null, + chunkname: string | null, + mode: string | null, + ) => LuaReturn + public lua_dump: ( + L: LuaState, + writer: number | null, + data: number | null, + strip: number, + ) => number public lua_yieldk: (L: LuaState, nresults: number, ctx: number, k: number | null) => number - public lua_resume: (L: LuaState, from: LuaState | null, narg: number, nres: number | null) => LuaReturn + public lua_resume: ( + L: LuaState, + from: LuaState | null, + narg: number, + nres: number | null, + ) => LuaReturn public lua_status: (L: LuaState) => LuaReturn public lua_isyieldable: (L: LuaState) => number public lua_setwarnf: (L: LuaState, f: number | null, ud: number | null) => void @@ -278,7 +340,13 @@ export default class LuaModule { public lua_getupvalue: (L: LuaState, funcindex: number, n: number) => string public lua_setupvalue: (L: LuaState, funcindex: number, n: number) => string public lua_upvalueid: (L: LuaState, fidx: number, n: number) => number - public lua_upvaluejoin: (L: LuaState, fidx1: number, n1: number, fidx2: number, n2: number) => void + public lua_upvaluejoin: ( + L: LuaState, + fidx1: number, + n1: number, + fidx2: number, + n2: number, + ) => void public lua_sethook: (L: LuaState, func: number | null, mask: number, count: number) => void public lua_gethook: (L: LuaState) => number public lua_gethookmask: (L: LuaState) => number @@ -304,42 +372,100 @@ export default class LuaModule { public constructor(module: LuaEmscriptenModule) { this._emscripten = module - this.luaL_checkversion_ = this.cwrap('luaL_checkversion_', null, ['number', 'number', 'number']) - this.luaL_getmetafield = this.cwrap('luaL_getmetafield', 'number', ['number', 'number', 'string']) + this.luaL_checkversion_ = this.cwrap('luaL_checkversion_', null, [ + 'number', + 'number', + 'number', + ]) + this.luaL_getmetafield = this.cwrap('luaL_getmetafield', 'number', [ + 'number', + 'number', + 'string', + ]) this.luaL_callmeta = this.cwrap('luaL_callmeta', 'number', ['number', 'number', 'string']) this.luaL_tolstring = this.cwrap('luaL_tolstring', 'string', ['number', 'number', 'number']) this.luaL_argerror = this.cwrap('luaL_argerror', 'number', ['number', 'number', 'string']) this.luaL_typeerror = this.cwrap('luaL_typeerror', 'number', ['number', 'number', 'string']) - this.luaL_checklstring = this.cwrap('luaL_checklstring', 'string', ['number', 'number', 'number']) - this.luaL_optlstring = this.cwrap('luaL_optlstring', 'string', ['number', 'number', 'string', 'number']) + this.luaL_checklstring = this.cwrap('luaL_checklstring', 'string', [ + 'number', + 'number', + 'number', + ]) + this.luaL_optlstring = this.cwrap('luaL_optlstring', 'string', [ + 'number', + 'number', + 'string', + 'number', + ]) this.luaL_checknumber = this.cwrap('luaL_checknumber', 'number', ['number', 'number']) this.luaL_optnumber = this.cwrap('luaL_optnumber', 'number', ['number', 'number', 'number']) this.luaL_checkinteger = this.cwrap('luaL_checkinteger', 'number', ['number', 'number']) - this.luaL_optinteger = this.cwrap('luaL_optinteger', 'number', ['number', 'number', 'number']) + this.luaL_optinteger = this.cwrap('luaL_optinteger', 'number', [ + 'number', + 'number', + 'number', + ]) this.luaL_checkstack = this.cwrap('luaL_checkstack', null, ['number', 'number', 'string']) this.luaL_checktype = this.cwrap('luaL_checktype', null, ['number', 'number', 'number']) this.luaL_checkany = this.cwrap('luaL_checkany', null, ['number', 'number']) this.luaL_newmetatable = this.cwrap('luaL_newmetatable', 'number', ['number', 'string']) this.luaL_setmetatable = this.cwrap('luaL_setmetatable', null, ['number', 'string']) this.luaL_testudata = this.cwrap('luaL_testudata', 'number', ['number', 'number', 'string']) - this.luaL_checkudata = this.cwrap('luaL_checkudata', 'number', ['number', 'number', 'string']) + this.luaL_checkudata = this.cwrap('luaL_checkudata', 'number', [ + 'number', + 'number', + 'string', + ]) this.luaL_where = this.cwrap('luaL_where', null, ['number', 'number']) - this.luaL_fileresult = this.cwrap('luaL_fileresult', 'number', ['number', 'number', 'string']) + this.luaL_fileresult = this.cwrap('luaL_fileresult', 'number', [ + 'number', + 'number', + 'string', + ]) this.luaL_execresult = this.cwrap('luaL_execresult', 'number', ['number', 'number']) this.luaL_ref = this.cwrap('luaL_ref', 'number', ['number', 'number']) this.luaL_unref = this.cwrap('luaL_unref', null, ['number', 'number', 'number']) this.luaL_loadfilex = this.cwrap('luaL_loadfilex', 'number', ['number', 'string', 'string']) - this.luaL_loadbufferx = this.cwrap('luaL_loadbufferx', 'number', ['number', 'string|number', 'number', 'string|number', 'string']) + this.luaL_loadbufferx = this.cwrap('luaL_loadbufferx', 'number', [ + 'number', + 'string|number', + 'number', + 'string|number', + 'string', + ]) this.luaL_loadstring = this.cwrap('luaL_loadstring', 'number', ['number', 'string']) this.luaL_newstate = this.cwrap('luaL_newstate', 'number', []) this.luaL_len = this.cwrap('luaL_len', 'number', ['number', 'number']) - this.luaL_addgsub = this.cwrap('luaL_addgsub', null, ['number', 'string', 'string', 'string']) + this.luaL_addgsub = this.cwrap('luaL_addgsub', null, [ + 'number', + 'string', + 'string', + 'string', + ]) this.luaL_gsub = this.cwrap('luaL_gsub', 'string', ['number', 'string', 'string', 'string']) this.luaL_setfuncs = this.cwrap('luaL_setfuncs', null, ['number', 'number', 'number']) - this.luaL_getsubtable = this.cwrap('luaL_getsubtable', 'number', ['number', 'number', 'string']) - this.luaL_traceback = this.cwrap('luaL_traceback', null, ['number', 'number', 'string', 'number']) - this.luaL_requiref = this.cwrap('luaL_requiref', null, ['number', 'string', 'number', 'number']) - this.luaL_openselectedlibs = this.cwrap('luaL_openselectedlibs', null, ['number', 'number', 'number']) + this.luaL_getsubtable = this.cwrap('luaL_getsubtable', 'number', [ + 'number', + 'number', + 'string', + ]) + this.luaL_traceback = this.cwrap('luaL_traceback', null, [ + 'number', + 'number', + 'string', + 'number', + ]) + this.luaL_requiref = this.cwrap('luaL_requiref', null, [ + 'number', + 'string', + 'number', + 'number', + ]) + this.luaL_openselectedlibs = this.cwrap('luaL_openselectedlibs', null, [ + 'number', + 'number', + 'number', + ]) this.luaL_buffinit = this.cwrap('luaL_buffinit', null, ['number', 'number']) this.luaL_prepbuffsize = this.cwrap('luaL_prepbuffsize', 'string', ['number', 'number']) this.luaL_addlstring = this.cwrap('luaL_addlstring', null, ['number', 'string', 'number']) @@ -347,12 +473,16 @@ export default class LuaModule { this.luaL_addvalue = this.cwrap('luaL_addvalue', null, ['number']) this.luaL_pushresult = this.cwrap('luaL_pushresult', null, ['number']) this.luaL_pushresultsize = this.cwrap('luaL_pushresultsize', null, ['number', 'number']) - this.luaL_buffinitsize = this.cwrap('luaL_buffinitsize', 'string', ['number', 'number', 'number']) + this.luaL_buffinitsize = this.cwrap('luaL_buffinitsize', 'string', [ + 'number', + 'number', + 'number', + ]) this.lua_newstate = this.cwrap('lua_newstate', 'number', ['number', 'number', 'number']) this.lua_close = this.cwrap('lua_close', null, ['number']) this.lua_newthread = this.cwrap('lua_newthread', 'number', ['number']) this.lua_closethread = this.cwrap('lua_closethread', 'number', ['number', 'number']) - this.lua_resetthread = (L) => this.lua_closethread(L, null) + this.lua_resetthread = L => this.lua_closethread(L, null) this.lua_atpanic = this.cwrap('lua_atpanic', 'number', ['number', 'number']) this.lua_version = this.cwrap('lua_version', 'number', ['number']) this.lua_absindex = this.cwrap('lua_absindex', 'number', ['number', 'number']) @@ -381,11 +511,20 @@ export default class LuaModule { this.lua_topointer = this.cwrap('lua_topointer', 'number', ['number', 'number']) this.lua_arith = this.cwrap('lua_arith', null, ['number', 'number']) this.lua_rawequal = this.cwrap('lua_rawequal', 'number', ['number', 'number', 'number']) - this.lua_compare = this.cwrap('lua_compare', 'number', ['number', 'number', 'number', 'number']) + this.lua_compare = this.cwrap('lua_compare', 'number', [ + 'number', + 'number', + 'number', + 'number', + ]) this.lua_pushnil = this.cwrap('lua_pushnil', null, ['number']) this.lua_pushnumber = this.cwrap('lua_pushnumber', null, ['number', 'number']) this.lua_pushinteger = this.cwrap('lua_pushinteger', null, ['number', 'number']) - this.lua_pushlstring = this.cwrap('lua_pushlstring', 'string', ['number', 'string|number', 'number']) + this.lua_pushlstring = this.cwrap('lua_pushlstring', 'string', [ + 'number', + 'string|number', + 'number', + ]) this.lua_pushstring = this.cwrap('lua_pushstring', 'string', ['number', 'string|number']) this.lua_pushcclosure = this.cwrap('lua_pushcclosure', null, ['number', 'number', 'number']) this.lua_pushboolean = this.cwrap('lua_pushboolean', null, ['number', 'number']) @@ -399,9 +538,17 @@ export default class LuaModule { this.lua_rawgeti = this.cwrap('lua_rawgeti', 'number', ['number', 'number', 'number']) this.lua_rawgetp = this.cwrap('lua_rawgetp', 'number', ['number', 'number', 'number']) this.lua_createtable = this.cwrap('lua_createtable', null, ['number', 'number', 'number']) - this.lua_newuserdatauv = this.cwrap('lua_newuserdatauv', 'number', ['number', 'number', 'number']) + this.lua_newuserdatauv = this.cwrap('lua_newuserdatauv', 'number', [ + 'number', + 'number', + 'number', + ]) this.lua_getmetatable = this.cwrap('lua_getmetatable', 'number', ['number', 'number']) - this.lua_getiuservalue = this.cwrap('lua_getiuservalue', 'number', ['number', 'number', 'number']) + this.lua_getiuservalue = this.cwrap('lua_getiuservalue', 'number', [ + 'number', + 'number', + 'number', + ]) this.lua_setglobal = this.cwrap('lua_setglobal', null, ['number', 'string']) this.lua_settable = this.cwrap('lua_settable', null, ['number', 'number']) this.lua_setfield = this.cwrap('lua_setfield', null, ['number', 'number', 'string']) @@ -410,13 +557,46 @@ export default class LuaModule { this.lua_rawseti = this.cwrap('lua_rawseti', null, ['number', 'number', 'number']) this.lua_rawsetp = this.cwrap('lua_rawsetp', null, ['number', 'number', 'number']) this.lua_setmetatable = this.cwrap('lua_setmetatable', 'number', ['number', 'number']) - this.lua_setiuservalue = this.cwrap('lua_setiuservalue', 'number', ['number', 'number', 'number']) - this.lua_callk = this.cwrap('lua_callk', null, ['number', 'number', 'number', 'number', 'number']) - this.lua_pcallk = this.cwrap('lua_pcallk', 'number', ['number', 'number', 'number', 'number', 'number', 'number']) - this.lua_load = this.cwrap('lua_load', 'number', ['number', 'number', 'number', 'string', 'string']) + this.lua_setiuservalue = this.cwrap('lua_setiuservalue', 'number', [ + 'number', + 'number', + 'number', + ]) + this.lua_callk = this.cwrap('lua_callk', null, [ + 'number', + 'number', + 'number', + 'number', + 'number', + ]) + this.lua_pcallk = this.cwrap('lua_pcallk', 'number', [ + 'number', + 'number', + 'number', + 'number', + 'number', + 'number', + ]) + this.lua_load = this.cwrap('lua_load', 'number', [ + 'number', + 'number', + 'number', + 'string', + 'string', + ]) this.lua_dump = this.cwrap('lua_dump', 'number', ['number', 'number', 'number', 'number']) - this.lua_yieldk = this.cwrap('lua_yieldk', 'number', ['number', 'number', 'number', 'number']) - this.lua_resume = this.cwrap('lua_resume', 'number', ['number', 'number', 'number', 'number']) + this.lua_yieldk = this.cwrap('lua_yieldk', 'number', [ + 'number', + 'number', + 'number', + 'number', + ]) + this.lua_resume = this.cwrap('lua_resume', 'number', [ + 'number', + 'number', + 'number', + 'number', + ]) this.lua_status = this.cwrap('lua_status', 'number', ['number']) this.lua_isyieldable = this.cwrap('lua_isyieldable', 'number', ['number']) this.lua_setwarnf = this.cwrap('lua_setwarnf', null, ['number', 'number', 'number']) @@ -437,7 +617,13 @@ export default class LuaModule { this.lua_getupvalue = this.cwrap('lua_getupvalue', 'string', ['number', 'number', 'number']) this.lua_setupvalue = this.cwrap('lua_setupvalue', 'string', ['number', 'number', 'number']) this.lua_upvalueid = this.cwrap('lua_upvalueid', 'number', ['number', 'number', 'number']) - this.lua_upvaluejoin = this.cwrap('lua_upvaluejoin', null, ['number', 'number', 'number', 'number', 'number']) + this.lua_upvaluejoin = this.cwrap('lua_upvaluejoin', null, [ + 'number', + 'number', + 'number', + 'number', + 'number', + ]) this.lua_sethook = this.cwrap('lua_sethook', null, ['number', 'number', 'number', 'number']) this.lua_gethook = this.cwrap('lua_gethook', 'number', ['number']) this.lua_gethookmask = this.cwrap('lua_gethookmask', 'number', ['number']) @@ -454,7 +640,7 @@ export default class LuaModule { this.luaopen_math = this.cwrap('luaopen_math', 'number', ['number']) this.luaopen_debug = this.cwrap('luaopen_debug', 'number', ['number']) this.luaopen_package = this.cwrap('luaopen_package', 'number', ['number']) - this.luaL_openlibs = (L) => this.luaL_openselectedlibs(L, -1, 0) + this.luaL_openlibs = L => this.luaL_openselectedlibs(L, -1, 0) } public lua_remove(luaState: LuaState, index: number): void { @@ -540,10 +726,15 @@ export default class LuaModule { argTypes: Array, ): (...args: any[]) => any { // optimization for common case - const hasStringOrNumber = argTypes.some((argType) => argType === 'string|number') + const hasStringOrNumber = argTypes.some(argType => argType === 'string|number') if (!hasStringOrNumber) { return (...args: any[]) => - this._emscripten.ccall(name, returnType, argTypes as Emscripten.JSType[], args as Emscripten.TypeCompatibleWithC[]) + this._emscripten.ccall( + name, + returnType, + argTypes as Emscripten.JSType[], + args as Emscripten.TypeCompatibleWithC[], + ) } return (...args: any[]) => { @@ -555,7 +746,9 @@ export default class LuaModule { } else { // because it will be freed later, this can only be used on functions that lua internally copies the string if (args[i]?.length > 1024) { - const bufferPointer = this._emscripten.stringToNewUTF8(args[i] as string) + const bufferPointer = this._emscripten.stringToNewUTF8( + args[i] as string, + ) args[i] = bufferPointer pointersToBeFreed.push(bufferPointer) return 'number' @@ -568,7 +761,12 @@ export default class LuaModule { }) try { - return this._emscripten.ccall(name, returnType, resolvedArgTypes, args as Emscripten.TypeCompatibleWithC[]) + return this._emscripten.ccall( + name, + returnType, + resolvedArgTypes, + args as Emscripten.TypeCompatibleWithC[], + ) } finally { for (const pointer of pointersToBeFreed) { this._emscripten._free(pointer) @@ -578,7 +776,9 @@ export default class LuaModule { } } -function createOutputWriter(writer?: (content: string) => void): ((charCode: number | null) => void) | null { +function createOutputWriter( + writer?: (content: string) => void, +): ((charCode: number | null) => void) | null { if (!writer) { return null } diff --git a/test/browser.test.js b/test/browser.test.js new file mode 100644 index 0000000..6a8d826 --- /dev/null +++ b/test/browser.test.js @@ -0,0 +1,200 @@ +import { createServer } from 'node:http' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { expect } from 'chai' +import { chromium } from 'playwright' + +const DIST_DIR = new URL('../dist', import.meta.url).pathname + +function startServer() { + const mimeTypes = { + '.html': 'text/html', + '.js': 'application/javascript', + '.wasm': 'application/wasm', + '.map': 'application/json', + } + + const testPage = ` + + +` + + const server = createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost`) + + if (url.pathname === '/' || url.pathname === '/index.html') { + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end(testPage) + return + } + + const filePath = join(DIST_DIR, url.pathname) + + try { + const data = await readFile(filePath) + const ext = url.pathname.substring(url.pathname.lastIndexOf('.')) + res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' }) + res.end(data) + } catch { + res.writeHead(404) + res.end('Not found') + } + }) + + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() + resolve({ server, port }) + }) + }) +} + +describe('Browser environment', () => { + let server, port, browser, context + + before(async function () { + this.timeout(30_000) + ; ({ server, port } = await startServer()) + browser = await chromium.launch() + context = await browser.newContext() + }) + + after(async () => { + await context?.close() + await browser?.close() + server?.close() + }) + + async function runInBrowser(code) { + const page = await context.newPage() + const errors = [] + page.on('pageerror', (err) => errors.push(err)) + + try { + await page.goto(`http://127.0.0.1:${port}/`) + await page.waitForFunction(() => window.__ready === true, null, { timeout: 15_000 }) + + const result = await page.evaluate(async (code) => { + return await window.__runTest(code) + }, code) + + if (errors.length > 0) { + throw errors[0] + } + return result + } finally { + await page.close() + } + } + + it('load Lua engine in browser should succeed', async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const lua = await Lua.load({ wasmFile }) + const state = lua.createState() + return state !== undefined + `) + expect(result).to.be.true + }) + + it('execute Lua code in browser should succeed', async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const lua = await Lua.load({ wasmFile }) + const state = lua.createState() + return await state.doString('return 2 + 2') + `) + expect(result).to.be.equal(4) + }) + + it('pass JS values to Lua and back in browser should succeed', async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const lua = await Lua.load({ wasmFile }) + const state = lua.createState({ injectObjects: true }) + state.global.set('name', 'wasmoon') + return await state.doString('return "hello " .. name') + `) + expect(result).to.be.equal('hello wasmoon') + }) + + it('call JS function from Lua in browser should succeed', async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const lua = await Lua.load({ wasmFile }) + const state = lua.createState({ injectObjects: true }) + state.global.set('add', (a, b) => a + b) + return await state.doString('return add(10, 20)') + `) + expect(result).to.be.equal(30) + }) + + it('mount and require a file in browser should succeed', async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const lua = await Lua.load({ wasmFile }) + lua.mountFile('mymodule.lua', 'return 42') + const state = lua.createState() + return await state.doString('return require("mymodule")') + `) + expect(result).to.be.equal(42) + }) + + it('handle Lua tables as JS objects in browser should succeed', async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const lua = await Lua.load({ wasmFile }) + const state = lua.createState({ injectObjects: true }) + const value = await state.doString('return { x = 10, y = 20 }') + return { x: value.x, y: value.y } + `) + expect(result).to.be.eql({ x: 10, y: 20 }) + }) + + it('handle errors in browser should succeed', async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const lua = await Lua.load({ wasmFile }) + const state = lua.createState() + try { + await state.doString('error("test error")') + return false + } catch (e) { + return e.message.includes('test error') + } + `) + expect(result).to.be.true + }) + + it('use coroutines in browser should succeed', async function () { + this.timeout(30_000) + const result = await runInBrowser(` + const lua = await Lua.load({ wasmFile }) + const state = lua.createState() + return await state.doString(\` + local co = coroutine.create(function() + coroutine.yield(1) + coroutine.yield(2) + return 3 + end) + local results = {} + while true do + local ok, value = coroutine.resume(co) + if not ok then break end + results[#results + 1] = value + if coroutine.status(co) == "dead" then break end + end + return results[1] + results[2] + results[3] + \`) + `) + expect(result).to.be.equal(6) + }) +}) diff --git a/test/node.test.js b/test/node.test.js new file mode 100644 index 0000000..51d8dcb --- /dev/null +++ b/test/node.test.js @@ -0,0 +1,250 @@ +import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { expect } from 'chai' +import { Lua } from '../dist/index.js' + +describe('Node environment', () => { + it('load Lua engine in node should succeed', async () => { + const lua = await Lua.load() + const state = lua.createState() + const result = await state.doString('return 1 + 1') + expect(result).to.be.equal(2) + }) + + it('access environment variables should succeed', async () => { + const env = { MY_TEST_VAR: 'hello_from_node' } + const lua = await Lua.load({ env }) + const state = lua.createState() + const result = await state.doString('return os.getenv("MY_TEST_VAR")') + expect(result).to.be.equal('hello_from_node') + }) + + it('custom stdout should succeed', async () => { + const output = [] + const lua = await Lua.load({ + stdout: (content) => output.push(content), + }) + const state = lua.createState() + await state.doString('print("hello from node")') + expect(output.join('')).to.include('hello from node') + }) + + it('custom stderr should succeed', async () => { + const errors = [] + const lua = await Lua.load({ + stderr: (content) => errors.push(content), + }) + const state = lua.createState() + await state.doString('io.stderr:write("error output\\n")') + expect(errors.join('')).to.include('error output') + }) + + it('mount file and require in node should succeed', async () => { + const lua = await Lua.load() + lua.mountFile('nodetest.lua', 'return { value = 99 }') + const state = lua.createState({ injectObjects: true }) + const result = await state.doString('local m = require("nodetest"); return m.value') + expect(result).to.be.equal(99) + }) + + it('mount file with binary content should succeed', async () => { + const lua = await Lua.load() + const content = new Uint8Array([114, 101, 116, 117, 114, 110, 32, 52, 50]) // "return 42" + lua.mountFile('binary.lua', content) + const state = lua.createState() + const result = await state.doString('return dofile("binary.lua")') + expect(result).to.be.equal(42) + }) + + it('mount file in nested directories should succeed', async () => { + const lua = await Lua.load() + lua.mountFile('a/b/c/deep.lua', 'return "deep"') + const state = lua.createState() + const result = await state.doString('return require("a/b/c/deep")') + expect(result).to.be.equal('deep') + }) + + it('create multiple independent states should succeed', async () => { + const lua = await Lua.load() + const state1 = lua.createState() + const state2 = lua.createState() + + await state1.doString('x = 10') + await state2.doString('x = 20') + + const val1 = await state1.doString('return x') + const val2 = await state2.doString('return x') + + expect(val1).to.be.equal(10) + expect(val2).to.be.equal(20) + }) + + it('pass complex JS objects to Lua should succeed', async () => { + const state = (await Lua.load()).createState({ injectObjects: true }) + state.global.set('data', { + name: 'test', + nested: { value: 42 }, + items: [1, 2, 3], + }) + const result = await state.doString('return data.nested.value') + expect(result).to.be.equal(42) + }) + + it('call JS async functions from Lua should succeed', async () => { + const state = (await Lua.load()).createState({ injectObjects: true }) + state.global.set('fetchData', async () => { + return 'async result' + }) + const result = await state.doString('return fetchData():await()') + expect(result).to.be.equal('async result') + }) + + it('use NODEFS to read host files should succeed', async () => { + const tempDir = join(tmpdir(), `wasmoon-test-${Date.now()}`) + mkdirSync(tempDir, { recursive: true }) + const testFile = join(tempDir, 'test.txt') + writeFileSync(testFile, 'hello from host') + + try { + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doString(` + local f = io.open("${testFile.replace(/\\/g, '/')}", "r") + local content = f:read("*a") + f:close() + return content + `) + expect(result).to.be.equal('hello from host') + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('use NODEFS to write and read back files should succeed', async () => { + const tempDir = join(tmpdir(), `wasmoon-test-${Date.now()}`) + mkdirSync(tempDir, { recursive: true }) + const testFile = join(tempDir, 'output.txt') + + try { + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + await state.doString(` + local f = io.open("${testFile.replace(/\\/g, '/')}", "w") + f:write("written from lua") + f:close() + `) + const content = readFileSync(testFile, 'utf8') + expect(content).to.be.equal('written from lua') + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('NODEFS cwd should match process.cwd', async () => { + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + // Write a temp file in the CWD and verify it can be read + const markerFile = join(process.cwd(), `.wasmoon-cwd-test-${Date.now()}.tmp`) + writeFileSync(markerFile, 'cwd-marker') + try { + const result = await state.doString(` + local f = io.open("${markerFile.replace(/\\/g, '/')}", "r") + if not f then return nil end + local content = f:read("*a") + f:close() + return content + `) + expect(result).to.be.equal('cwd-marker') + } finally { + rmSync(markerFile, { force: true }) + } + }) + + it('NODEFS with fsMountPaths should only mount specified paths', async () => { + const tempDir = join(tmpdir(), `wasmoon-mount-test-${Date.now()}`) + mkdirSync(tempDir, { recursive: true }) + const testFile = join(tempDir, 'specific.txt') + writeFileSync(testFile, 'mounted content') + + try { + const lua = await Lua.load({ fs: 'node', fsMountPaths: [tmpdir()] }) + const state = lua.createState() + const result = await state.doString(` + local f = io.open("${testFile.replace(/\\/g, '/')}", "r") + if not f then return nil end + local content = f:read("*a") + f:close() + return content + `) + expect(result).to.be.equal('mounted content') + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('NODEFS opening nonexistent file should return nil', async () => { + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doString(` + local f, err = io.open("/tmp/wasmoon_nonexistent_file_${Date.now()}.txt", "r") + return f == nil and type(err) == "string" + `) + expect(result).to.be.true + }) + + it('mountFile and NODEFS should coexist', async () => { + const tempDir = join(tmpdir(), `wasmoon-coexist-${Date.now()}`) + mkdirSync(tempDir, { recursive: true }) + const hostFile = join(tempDir, 'host.txt') + writeFileSync(hostFile, 'from host') + + try { + const lua = await Lua.load({ fs: 'node' }) + lua.mountFile('virtual.lua', 'return "from virtual"') + const state = lua.createState() + + const hostContent = await state.doString(` + local f = io.open("${hostFile.replace(/\\/g, '/')}", "r") + local content = f:read("*a") + f:close() + return content + `) + const virtualContent = await state.doString('return dofile("virtual.lua")') + + expect(hostContent).to.be.equal('from host') + expect(virtualContent).to.be.equal('from virtual') + } finally { + rmSync(tempDir, { recursive: true, force: true }) + rmSync('virtual.lua', { force: true }) + } + }) + + it('standard libraries are available should succeed', async () => { + const state = (await Lua.load()).createState() + const result = await state.doString(` + local checks = {} + checks[1] = type(string.format) == "function" + checks[2] = type(table.insert) == "function" + checks[3] = type(math.floor) == "function" + checks[4] = type(os.time) == "function" + checks[5] = type(coroutine.create) == "function" + for _, v in ipairs(checks) do + if not v then return false end + end + return true + `) + expect(result).to.be.true + }) + + it('error handling with pcall should succeed', async () => { + const state = (await Lua.load()).createState() + const result = await state.doString(` + local ok, err = pcall(function() + error("node test error") + end) + return not ok and string.find(err, "node test error") ~= nil + `) + expect(result).to.be.true + }) +}) diff --git a/test/nodefs.test.js b/test/nodefs.test.js new file mode 100644 index 0000000..166530f --- /dev/null +++ b/test/nodefs.test.js @@ -0,0 +1,410 @@ +import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, symlinkSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { expect } from 'chai' +import { Lua } from '../dist/index.js' + +const createTempDir = () => { + const dir = join(tmpdir(), `wasmoon-nodefs-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(dir, { recursive: true }) + return dir +} + +describe('Node FS', () => { + let tempDir + + beforeEach(() => { + tempDir = createTempDir() + }) + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + + it('read a host file should succeed', async () => { + writeFileSync(join(tempDir, 'hello.txt'), 'hello world') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doString(` + local f = io.open("${join(tempDir, 'hello.txt').replace(/\\/g, '/')}", "r") + local content = f:read("*a") + f:close() + return content + `) + + expect(result).to.be.equal('hello world') + }) + + it('write a file to host should succeed', async () => { + const filePath = join(tempDir, 'output.txt') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + await state.doString(` + local f = io.open("${filePath.replace(/\\/g, '/')}", "w") + f:write("written from lua") + f:close() + `) + + expect(readFileSync(filePath, 'utf8')).to.be.equal('written from lua') + }) + + it('append to a host file should succeed', async () => { + const filePath = join(tempDir, 'append.txt') + writeFileSync(filePath, 'first line\n') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + await state.doString(` + local f = io.open("${filePath.replace(/\\/g, '/')}", "a") + f:write("second line\\n") + f:close() + `) + + expect(readFileSync(filePath, 'utf8')).to.be.equal('first line\nsecond line\n') + }) + + it('read file line by line should succeed', async () => { + writeFileSync(join(tempDir, 'lines.txt'), 'alpha\nbeta\ngamma\n') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doString(` + local lines = {} + for line in io.lines("${join(tempDir, 'lines.txt').replace(/\\/g, '/')}") do + lines[#lines + 1] = line + end + return table.concat(lines, ",") + `) + + expect(result).to.be.equal('alpha,beta,gamma') + }) + + it('read binary file should succeed', async () => { + const buf = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe]) + writeFileSync(join(tempDir, 'binary.dat'), buf) + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doString(` + local f = io.open("${join(tempDir, 'binary.dat').replace(/\\/g, '/')}", "rb") + local data = f:read("*a") + f:close() + return #data + `) + + expect(result).to.be.equal(5) + }) + + it('write and read back binary data should succeed', async () => { + const filePath = join(tempDir, 'binout.dat') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + await state.doString(` + local f = io.open("${filePath.replace(/\\/g, '/')}", "wb") + f:write(string.char(72, 101, 108, 108, 111)) + f:close() + `) + + expect(readFileSync(filePath, 'utf8')).to.be.equal('Hello') + }) + + it('check if file exists using io.open should succeed', async () => { + writeFileSync(join(tempDir, 'exists.txt'), 'yes') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doString(` + local f = io.open("${join(tempDir, 'exists.txt').replace(/\\/g, '/')}", "r") + local exists = f ~= nil + if f then f:close() end + + local f2 = io.open("${join(tempDir, 'nope.txt').replace(/\\/g, '/')}", "r") + local not_exists = f2 == nil + if f2 then f2:close() end + + return exists and not_exists + `) + + expect(result).to.be.true + }) + + it('read file size should succeed', async () => { + writeFileSync(join(tempDir, 'sized.txt'), 'abcdefghij') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doString(` + local f = io.open("${join(tempDir, 'sized.txt').replace(/\\/g, '/')}", "r") + local size = f:seek("end") + f:close() + return size + `) + + expect(result).to.be.equal(10) + }) + + it('seek within a file should succeed', async () => { + writeFileSync(join(tempDir, 'seek.txt'), 'abcdefghij') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doString(` + local f = io.open("${join(tempDir, 'seek.txt').replace(/\\/g, '/')}", "r") + f:seek("set", 3) + local chunk = f:read(4) + f:close() + return chunk + `) + + expect(result).to.be.equal('defg') + }) + + it('doFile from host filesystem should succeed', async () => { + writeFileSync(join(tempDir, 'script.lua'), 'return 7 * 6') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doFile(join(tempDir, 'script.lua').replace(/\\/g, '/')) + + expect(result).to.be.equal(42) + }) + + it('require a host lua file should succeed', async () => { + const luaDir = join(tempDir, 'lualibs') + mkdirSync(luaDir, { recursive: true }) + writeFileSync(join(luaDir, 'mymod.lua'), 'local M = {}; M.value = 99; return M') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState({ injectObjects: true }) + const result = await state.doString(` + package.path = "${luaDir.replace(/\\/g, '/')}/?.lua;" .. package.path + local m = require("mymod") + return m.value + `) + + expect(result).to.be.equal(99) + }) + + it('read from nested directories should succeed', async () => { + const nested = join(tempDir, 'a', 'b', 'c') + mkdirSync(nested, { recursive: true }) + writeFileSync(join(nested, 'deep.txt'), 'deep content') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doString(` + local f = io.open("${join(nested, 'deep.txt').replace(/\\/g, '/')}", "r") + local content = f:read("*a") + f:close() + return content + `) + + expect(result).to.be.equal('deep content') + }) + + it('create directory from lua with os.execute should succeed', async () => { + const newDir = join(tempDir, 'newdir') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + await state.doString(` + os.execute("mkdir -p '${newDir.replace(/\\/g, '/')}'") + `) + + expect(existsSync(newDir)).to.be.true + }) + + it('remove a host file from lua with os.remove should succeed', async () => { + const filePath = join(tempDir, 'removeme.txt') + writeFileSync(filePath, 'to be deleted') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doString(` + return os.remove("${filePath.replace(/\\/g, '/')}") + `) + + expect(result).to.be.true + expect(existsSync(filePath)).to.be.false + }) + + it('rename a host file from lua with os.rename should succeed', async () => { + const oldPath = join(tempDir, 'old.txt') + const newPath = join(tempDir, 'new.txt') + writeFileSync(oldPath, 'rename me') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doString(` + return os.rename("${oldPath.replace(/\\/g, '/')}", "${newPath.replace(/\\/g, '/')}") + `) + + expect(result).to.be.true + expect(existsSync(oldPath)).to.be.false + expect(readFileSync(newPath, 'utf8')).to.be.equal('rename me') + }) + + it('doFile from cwd-relative path should succeed', async () => { + // NODEFS sets cwd to process.cwd(), so relative doFile should work + const lua = await Lua.load({ fs: 'node' }) + lua.mountFile('cwd_test.lua', 'return "from cwd"') + try { + const state = lua.createState() + const result = await state.doFile('cwd_test.lua') + + expect(result).to.be.equal('from cwd') + } finally { + rmSync('cwd_test.lua', { force: true }) + } + }) + + it('write large file should succeed', async () => { + const filePath = join(tempDir, 'large.txt') + const lineCount = 10000 + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + await state.doString(` + local f = io.open("${filePath.replace(/\\/g, '/')}", "w") + for i = 1, ${lineCount} do + f:write("line " .. i .. "\\n") + end + f:close() + `) + + const content = readFileSync(filePath, 'utf8') + const lines = content.trimEnd().split('\n') + expect(lines.length).to.be.equal(lineCount) + expect(lines[0]).to.be.equal('line 1') + expect(lines[lineCount - 1]).to.be.equal(`line ${lineCount}`) + }) + + it('overwrite existing file should succeed', async () => { + const filePath = join(tempDir, 'overwrite.txt') + writeFileSync(filePath, 'original content') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + await state.doString(` + local f = io.open("${filePath.replace(/\\/g, '/')}", "w") + f:write("new content") + f:close() + `) + + expect(readFileSync(filePath, 'utf8')).to.be.equal('new content') + }) + + it('read UTF-8 content should succeed', async () => { + const content = 'héllo wörld 日本語 🌍' + writeFileSync(join(tempDir, 'utf8.txt'), content) + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doString(` + local f = io.open("${join(tempDir, 'utf8.txt').replace(/\\/g, '/')}", "r") + local data = f:read("*a") + f:close() + return data + `) + + expect(result).to.be.equal(content) + }) + + it('write UTF-8 content should succeed', async () => { + const filePath = join(tempDir, 'utf8out.txt') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + await state.doString(` + local f = io.open("${filePath.replace(/\\/g, '/')}", "w") + f:write("café ñ 中文") + f:close() + `) + + expect(readFileSync(filePath, 'utf8')).to.be.equal('café ñ 中文') + }) + + it('read empty file should succeed', async () => { + writeFileSync(join(tempDir, 'empty.txt'), '') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doString(` + local f = io.open("${join(tempDir, 'empty.txt').replace(/\\/g, '/')}", "r") + local data = f:read("*a") + f:close() + return #data + `) + + expect(result).to.be.equal(0) + }) + + it('open non-existent file for reading should return nil', async () => { + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doString(` + local f, err = io.open("${join(tempDir, 'nonexistent.txt').replace(/\\/g, '/')}", "r") + return f == nil + `) + + expect(result).to.be.true + }) + + it('io.tmpfile should succeed', async () => { + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doString(` + local f = io.tmpfile() + f:write("temp data") + f:seek("set") + local data = f:read("*a") + f:close() + return data + `) + + expect(result).to.be.equal('temp data') + }) + + it('multiple states sharing the same NODEFS should succeed', async () => { + const filePath = join(tempDir, 'shared.txt') + + const lua = await Lua.load({ fs: 'node' }) + const state1 = lua.createState() + const state2 = lua.createState() + + await state1.doString(` + local f = io.open("${filePath.replace(/\\/g, '/')}", "w") + f:write("from state1") + f:close() + `) + + const result = await state2.doString(` + local f = io.open("${filePath.replace(/\\/g, '/')}", "r") + local data = f:read("*a") + f:close() + return data + `) + + expect(result).to.be.equal('from state1') + }) + + it('read number from file should succeed', async () => { + writeFileSync(join(tempDir, 'numbers.txt'), '42\n3.14\n100') + + const lua = await Lua.load({ fs: 'node' }) + const state = lua.createState() + const result = await state.doString(` + local f = io.open("${join(tempDir, 'numbers.txt').replace(/\\/g, '/')}", "r") + local n1 = f:read("*n") + local n2 = f:read("*n") + local n3 = f:read("*n") + f:close() + return n1 + n2 + n3 + `) + + expect(result).to.be.closeTo(145.14, 0.001) + }) +}) From dbcb003641754e1b98079da334787cd563949dd4 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Tue, 24 Mar 2026 21:53:21 -0300 Subject: [PATCH 34/52] nodefs: load the current drive by default --- README.md | 8 ++-- src/module.ts | 91 +++++++++++++++++++++++++------------------- src/thread.ts | 2 +- test/browser.test.js | 2 +- tsconfig.json | 3 +- 5 files changed, 60 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 9d42ebd..f8f2543 100644 --- a/README.md +++ b/README.md @@ -80,10 +80,10 @@ Wasmoon compiles the [official Lua code](https://github.com/lua/lua) to WebAssem Because of WebAssembly, wasmoon runs Lua code significantly faster than fengari. The table below shows results from a [heap sort benchmark](https://github.com/ceifa/wasmoon/blob/main/bench/heapsort.lua) sorting a list of 2,000 numbers (100 iterations, 5 warmup): -| | avg | median | min | max | stddev | relative | -| ---------------- | ---------- | ---------- | ---------- | ---------- | --------- | -------- | -| **Wasmoon** | 13.41 ms | 13.07 ms | 12.20 ms | 16.23 ms | 1.12 ms | 1.00x | -| **Fengari** | 137.36 ms | 138.51 ms | 119.70 ms | 165.54 ms | 11.16 ms | 10.24x | +| | avg | median | min | max | stddev | relative | +| ----------- | --------- | --------- | --------- | --------- | -------- | -------- | +| **Wasmoon** | 13.41 ms | 13.07 ms | 12.20 ms | 16.23 ms | 1.12 ms | 1.00x | +| **Fengari** | 137.36 ms | 138.51 ms | 119.70 ms | 165.54 ms | 11.16 ms | 10.24x | Wasmoon is **~10x faster** than fengari for pure Lua execution. If your use case involves heavy interop between JS and Lua, the difference may be smaller, benchmark your specific scenario. diff --git a/src/module.ts b/src/module.ts index d9590a0..2a1fd7e 100755 --- a/src/module.ts +++ b/src/module.ts @@ -21,7 +21,7 @@ interface LuaEmscriptenModule extends EmscriptenModule { PATH: { dirname: (typeof import('node:path'))['dirname'] } - stringToNewUTF8: typeof allocateUTF8 + stringToNewUTF8: typeof stringToNewUTF8 lengthBytesUTF8: typeof lengthBytesUTF8 stringToUTF8: typeof stringToUTF8 intArrayFromString: typeof intArrayFromString @@ -66,61 +66,74 @@ export default class LuaModule { } if (fs) { - let rootdirs: string[] - if (opts.fsMountPaths) { - rootdirs = opts.fsMountPaths - } else if (process.platform === 'win32') { - const drives: string[] = [] - for (let i = 65; i <= 90; i++) { - const drive = `${String.fromCharCode(i)}:\\` - try { - fs.accessSync(drive) - drives.push(drive) - } catch { - // drive does not exist - } - } + const cwd = process.cwd().replace(/\\/g, '/') + // Default to mounting the drive containing the CWD + const cwdDrive = process.platform === 'win32' ? cwd.slice(0, 3) : '/' + const mountPaths = opts.fsMountPaths ?? [cwdDrive] + + // Deduplicate and remove paths that are children of other mount paths + const normalized = [...new Set(mountPaths.map(p => p.replace(/\\/g, '/')))] + const filtered = normalized.filter( + path => + !normalized.some( + other => other !== path && path.startsWith(other + '/'), + ), + ) - rootdirs = [] - for (const drive of drives) { + // Expand drive roots into their subdirectories, since + // Emscripten's VFS already owns "/" and cannot be mounted over. + // Skip virtual/system filesystems that cause issues with NODEFS. + const skipDirs = [ + 'dev', + 'proc', + 'sys', + 'run', + 'snap', + 'System Volume Information', + '$Recycle.Bin', + 'Recovery', + ] + const expanded: string[] = [] + for (const dir of filtered) { + const isDriveRoot = dir === '/' || /^[A-Za-z]:\/$/.test(dir) + if (isDriveRoot) { try { - rootdirs.push( - ...fs - .readdirSync(drive) - .filter(dir => !['dev', 'lib', 'proc'].includes(dir)) - .map(dir => `${drive}${dir}`.replace(/\\/g, '/')), - ) + const children = fs + .readdirSync(dir) + .filter((child: string) => !skipDirs.includes(child)) + .map((child: string) => + dir === '/' + ? `/${child}` + : `${dir}${child}`.replace(/\\/g, '/'), + ) + expanded.push(...children) } catch { // drive not readable } - } - } else { - try { - rootdirs = fs - .readdirSync('/') - .filter(dir => !['dev', 'lib', 'proc'].includes(dir)) - .map(dir => `/${dir}`) - } catch { - rootdirs = [] + } else { + expanded.push(dir) } } - for (const dir of rootdirs) { + for (const dir of expanded) { try { const moduleFS = initializedModule.FS moduleFS.mkdirTree(dir) moduleFS.mount(moduleFS.filesystems.NODEFS, { root: dir }, dir) } catch (err: any) { - const isPermError = - err?.code === 'EACCES' || err?.code === 'EPERM' || err?.errno === 2 - if (!isPermError) { + // Ignore permission/not-found errors from both Node.js + // (string .code) and Emscripten ErrnoError (numeric .errno) + const isIgnorableError = + ['EACCES', 'EPERM', 'ENOENT'].includes(err?.code) || + err?.name === 'ErrnoError' + if (!isIgnorableError) { console.warn(`Failed to mount ${dir}:`, err) } } } try { - initializedModule.FS.chdir(process.cwd().replace(/\\/g, '/')) + initializedModule.FS.chdir(cwd) } catch { // CWD may not be within mounted paths } @@ -142,12 +155,12 @@ export default class LuaModule { } } - if (bufferedInput.length === 0) { + if (bufferedInput!.length === 0) { bufferedInput = undefined return null } - const item = bufferedInput.shift() + const item = bufferedInput!.shift() return !item || item === 0 ? null : item } : null, diff --git a/src/thread.ts b/src/thread.ts index bd9d832..b158efe 100755 --- a/src/thread.ts +++ b/src/thread.ts @@ -330,7 +330,7 @@ export default class Thread { }, 'vii') } - this.lua.lua_sethook(this.address, this.hookFunctionPointer, LuaEventMasks.Count, INSTRUCTION_HOOK_COUNT) + this.lua.lua_sethook(this.address, this.hookFunctionPointer!, LuaEventMasks.Count, INSTRUCTION_HOOK_COUNT) this.timeout = timeout } else if (this.hookFunctionPointer) { this.hookFunctionPointer = undefined diff --git a/test/browser.test.js b/test/browser.test.js index 6a8d826..da0f295 100644 --- a/test/browser.test.js +++ b/test/browser.test.js @@ -62,7 +62,7 @@ describe('Browser environment', () => { before(async function () { this.timeout(30_000) - ; ({ server, port } = await startServer()) + ;({ server, port } = await startServer()) browser = await chromium.launch() context = await browser.newContext() }) diff --git a/tsconfig.json b/tsconfig.json index af4b756..54b02dd 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,8 +6,9 @@ "inlineSources": false, "removeComments": false, "sourceMap": false, - "target": "ES2022", + "target": "ES2024", "skipLibCheck": true, + "types": ["emscripten", "node"], "lib": ["ESNEXT", "DOM"], "forceConsistentCasingInFileNames": true, "noImplicitReturns": true, From 0980891d448577b12b211cad997d41671764f344 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Tue, 24 Mar 2026 21:59:38 -0300 Subject: [PATCH 35/52] lint --- .oxlintrc.json | 10 +- src/module.ts | 289 +++++++------------------------------------ test/browser.test.js | 4 +- test/nodefs.test.js | 2 +- 4 files changed, 58 insertions(+), 247 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index ceebf07..1769aea 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -9,5 +9,13 @@ "ignorePatterns": ["dist/**", "build/**", "rolldown.config.ts", "rolldown.config.*.js", "utils/**"], "env": { "builtin": true - } + }, + "overrides": [ + { + "files": ["test/**"], + "rules": { + "no-unused-expressions": "off" + } + } + ] } diff --git a/src/module.ts b/src/module.ts index 2a1fd7e..4a01496 100755 --- a/src/module.ts +++ b/src/module.ts @@ -72,27 +72,15 @@ export default class LuaModule { const mountPaths = opts.fsMountPaths ?? [cwdDrive] // Deduplicate and remove paths that are children of other mount paths - const normalized = [...new Set(mountPaths.map(p => p.replace(/\\/g, '/')))] + const normalized = [...new Set(mountPaths.map((p) => p.replace(/\\/g, '/')))] const filtered = normalized.filter( - path => - !normalized.some( - other => other !== path && path.startsWith(other + '/'), - ), + (path) => !normalized.some((other) => other !== path && path.startsWith(other + '/')), ) // Expand drive roots into their subdirectories, since // Emscripten's VFS already owns "/" and cannot be mounted over. // Skip virtual/system filesystems that cause issues with NODEFS. - const skipDirs = [ - 'dev', - 'proc', - 'sys', - 'run', - 'snap', - 'System Volume Information', - '$Recycle.Bin', - 'Recovery', - ] + const skipDirs = ['dev', 'proc', 'sys', 'run', 'snap', 'System Volume Information', '$Recycle.Bin', 'Recovery'] const expanded: string[] = [] for (const dir of filtered) { const isDriveRoot = dir === '/' || /^[A-Za-z]:\/$/.test(dir) @@ -101,11 +89,7 @@ export default class LuaModule { const children = fs .readdirSync(dir) .filter((child: string) => !skipDirs.includes(child)) - .map((child: string) => - dir === '/' - ? `/${child}` - : `${dir}${child}`.replace(/\\/g, '/'), - ) + .map((child: string) => (dir === '/' ? `/${child}` : `${dir}${child}`.replace(/\\/g, '/'))) expanded.push(...children) } catch { // drive not readable @@ -123,9 +107,7 @@ export default class LuaModule { } catch (err: any) { // Ignore permission/not-found errors from both Node.js // (string .code) and Emscripten ErrnoError (numeric .errno) - const isIgnorableError = - ['EACCES', 'EPERM', 'ENOENT'].includes(err?.code) || - err?.name === 'ErrnoError' + const isIgnorableError = ['EACCES', 'EPERM', 'ENOENT'].includes(err?.code) || err?.name === 'ErrnoError' if (!isIgnorableError) { console.warn(`Failed to mount ${dir}:`, err) } @@ -147,9 +129,7 @@ export default class LuaModule { if (!bufferedInput) { const input = opts.stdin?.() if (typeof input === 'string') { - bufferedInput = initializedModule - .intArrayFromString(input, true) - .concat([0]) + bufferedInput = initializedModule.intArrayFromString(input, true).concat([0]) } else { throw new Error('stdin must return a string') } @@ -182,12 +162,7 @@ export default class LuaModule { public luaL_argerror: (L: LuaState, arg: number, extramsg: string | null) => number public luaL_typeerror: (L: LuaState, arg: number, tname: string | null) => number public luaL_checklstring: (L: LuaState, arg: number, l: number | null) => string - public luaL_optlstring: ( - L: LuaState, - arg: number, - def: string | null, - l: number | null, - ) => string + public luaL_optlstring: (L: LuaState, arg: number, def: string | null, l: number | null) => string public luaL_checknumber: (L: LuaState, arg: number) => number public luaL_optnumber: (L: LuaState, arg: number, def: number) => number public luaL_checkinteger: (L: LuaState, arg: number) => number @@ -215,12 +190,7 @@ export default class LuaModule { public luaL_loadstring: (L: LuaState, s: string | null) => LuaReturn public luaL_newstate: () => LuaState public luaL_len: (L: LuaState, idx: number) => number - public luaL_addgsub: ( - b: number | null, - s: string | null, - p: string | null, - r: string | null, - ) => void + public luaL_addgsub: (b: number | null, s: string | null, p: string | null, r: string | null) => void public luaL_gsub: (L: LuaState, s: string | null, p: string | null, r: string | null) => string public luaL_setfuncs: (L: LuaState, l: number | null, nup: number) => void public luaL_getsubtable: (L: LuaState, idx: number, fname: string | null) => number @@ -298,41 +268,12 @@ export default class LuaModule { public lua_rawsetp: (L: LuaState, idx: number, p: number | null) => void public lua_setmetatable: (L: LuaState, objindex: number) => number public lua_setiuservalue: (L: LuaState, idx: number, n: number) => number - public lua_callk: ( - L: LuaState, - nargs: number, - nresults: number, - ctx: number, - k: number | null, - ) => void - public lua_pcallk: ( - L: LuaState, - nargs: number, - nresults: number, - errfunc: number, - ctx: number, - k: number | null, - ) => number - public lua_load: ( - L: LuaState, - reader: number | null, - dt: number | null, - chunkname: string | null, - mode: string | null, - ) => LuaReturn - public lua_dump: ( - L: LuaState, - writer: number | null, - data: number | null, - strip: number, - ) => number + public lua_callk: (L: LuaState, nargs: number, nresults: number, ctx: number, k: number | null) => void + public lua_pcallk: (L: LuaState, nargs: number, nresults: number, errfunc: number, ctx: number, k: number | null) => number + public lua_load: (L: LuaState, reader: number | null, dt: number | null, chunkname: string | null, mode: string | null) => LuaReturn + public lua_dump: (L: LuaState, writer: number | null, data: number | null, strip: number) => number public lua_yieldk: (L: LuaState, nresults: number, ctx: number, k: number | null) => number - public lua_resume: ( - L: LuaState, - from: LuaState | null, - narg: number, - nres: number | null, - ) => LuaReturn + public lua_resume: (L: LuaState, from: LuaState | null, narg: number, nres: number | null) => LuaReturn public lua_status: (L: LuaState) => LuaReturn public lua_isyieldable: (L: LuaState) => number public lua_setwarnf: (L: LuaState, f: number | null, ud: number | null) => void @@ -353,13 +294,7 @@ export default class LuaModule { public lua_getupvalue: (L: LuaState, funcindex: number, n: number) => string public lua_setupvalue: (L: LuaState, funcindex: number, n: number) => string public lua_upvalueid: (L: LuaState, fidx: number, n: number) => number - public lua_upvaluejoin: ( - L: LuaState, - fidx1: number, - n1: number, - fidx2: number, - n2: number, - ) => void + public lua_upvaluejoin: (L: LuaState, fidx1: number, n1: number, fidx2: number, n2: number) => void public lua_sethook: (L: LuaState, func: number | null, mask: number, count: number) => void public lua_gethook: (L: LuaState) => number public lua_gethookmask: (L: LuaState) => number @@ -385,100 +320,42 @@ export default class LuaModule { public constructor(module: LuaEmscriptenModule) { this._emscripten = module - this.luaL_checkversion_ = this.cwrap('luaL_checkversion_', null, [ - 'number', - 'number', - 'number', - ]) - this.luaL_getmetafield = this.cwrap('luaL_getmetafield', 'number', [ - 'number', - 'number', - 'string', - ]) + this.luaL_checkversion_ = this.cwrap('luaL_checkversion_', null, ['number', 'number', 'number']) + this.luaL_getmetafield = this.cwrap('luaL_getmetafield', 'number', ['number', 'number', 'string']) this.luaL_callmeta = this.cwrap('luaL_callmeta', 'number', ['number', 'number', 'string']) this.luaL_tolstring = this.cwrap('luaL_tolstring', 'string', ['number', 'number', 'number']) this.luaL_argerror = this.cwrap('luaL_argerror', 'number', ['number', 'number', 'string']) this.luaL_typeerror = this.cwrap('luaL_typeerror', 'number', ['number', 'number', 'string']) - this.luaL_checklstring = this.cwrap('luaL_checklstring', 'string', [ - 'number', - 'number', - 'number', - ]) - this.luaL_optlstring = this.cwrap('luaL_optlstring', 'string', [ - 'number', - 'number', - 'string', - 'number', - ]) + this.luaL_checklstring = this.cwrap('luaL_checklstring', 'string', ['number', 'number', 'number']) + this.luaL_optlstring = this.cwrap('luaL_optlstring', 'string', ['number', 'number', 'string', 'number']) this.luaL_checknumber = this.cwrap('luaL_checknumber', 'number', ['number', 'number']) this.luaL_optnumber = this.cwrap('luaL_optnumber', 'number', ['number', 'number', 'number']) this.luaL_checkinteger = this.cwrap('luaL_checkinteger', 'number', ['number', 'number']) - this.luaL_optinteger = this.cwrap('luaL_optinteger', 'number', [ - 'number', - 'number', - 'number', - ]) + this.luaL_optinteger = this.cwrap('luaL_optinteger', 'number', ['number', 'number', 'number']) this.luaL_checkstack = this.cwrap('luaL_checkstack', null, ['number', 'number', 'string']) this.luaL_checktype = this.cwrap('luaL_checktype', null, ['number', 'number', 'number']) this.luaL_checkany = this.cwrap('luaL_checkany', null, ['number', 'number']) this.luaL_newmetatable = this.cwrap('luaL_newmetatable', 'number', ['number', 'string']) this.luaL_setmetatable = this.cwrap('luaL_setmetatable', null, ['number', 'string']) this.luaL_testudata = this.cwrap('luaL_testudata', 'number', ['number', 'number', 'string']) - this.luaL_checkudata = this.cwrap('luaL_checkudata', 'number', [ - 'number', - 'number', - 'string', - ]) + this.luaL_checkudata = this.cwrap('luaL_checkudata', 'number', ['number', 'number', 'string']) this.luaL_where = this.cwrap('luaL_where', null, ['number', 'number']) - this.luaL_fileresult = this.cwrap('luaL_fileresult', 'number', [ - 'number', - 'number', - 'string', - ]) + this.luaL_fileresult = this.cwrap('luaL_fileresult', 'number', ['number', 'number', 'string']) this.luaL_execresult = this.cwrap('luaL_execresult', 'number', ['number', 'number']) this.luaL_ref = this.cwrap('luaL_ref', 'number', ['number', 'number']) this.luaL_unref = this.cwrap('luaL_unref', null, ['number', 'number', 'number']) this.luaL_loadfilex = this.cwrap('luaL_loadfilex', 'number', ['number', 'string', 'string']) - this.luaL_loadbufferx = this.cwrap('luaL_loadbufferx', 'number', [ - 'number', - 'string|number', - 'number', - 'string|number', - 'string', - ]) + this.luaL_loadbufferx = this.cwrap('luaL_loadbufferx', 'number', ['number', 'string|number', 'number', 'string|number', 'string']) this.luaL_loadstring = this.cwrap('luaL_loadstring', 'number', ['number', 'string']) this.luaL_newstate = this.cwrap('luaL_newstate', 'number', []) this.luaL_len = this.cwrap('luaL_len', 'number', ['number', 'number']) - this.luaL_addgsub = this.cwrap('luaL_addgsub', null, [ - 'number', - 'string', - 'string', - 'string', - ]) + this.luaL_addgsub = this.cwrap('luaL_addgsub', null, ['number', 'string', 'string', 'string']) this.luaL_gsub = this.cwrap('luaL_gsub', 'string', ['number', 'string', 'string', 'string']) this.luaL_setfuncs = this.cwrap('luaL_setfuncs', null, ['number', 'number', 'number']) - this.luaL_getsubtable = this.cwrap('luaL_getsubtable', 'number', [ - 'number', - 'number', - 'string', - ]) - this.luaL_traceback = this.cwrap('luaL_traceback', null, [ - 'number', - 'number', - 'string', - 'number', - ]) - this.luaL_requiref = this.cwrap('luaL_requiref', null, [ - 'number', - 'string', - 'number', - 'number', - ]) - this.luaL_openselectedlibs = this.cwrap('luaL_openselectedlibs', null, [ - 'number', - 'number', - 'number', - ]) + this.luaL_getsubtable = this.cwrap('luaL_getsubtable', 'number', ['number', 'number', 'string']) + this.luaL_traceback = this.cwrap('luaL_traceback', null, ['number', 'number', 'string', 'number']) + this.luaL_requiref = this.cwrap('luaL_requiref', null, ['number', 'string', 'number', 'number']) + this.luaL_openselectedlibs = this.cwrap('luaL_openselectedlibs', null, ['number', 'number', 'number']) this.luaL_buffinit = this.cwrap('luaL_buffinit', null, ['number', 'number']) this.luaL_prepbuffsize = this.cwrap('luaL_prepbuffsize', 'string', ['number', 'number']) this.luaL_addlstring = this.cwrap('luaL_addlstring', null, ['number', 'string', 'number']) @@ -486,16 +363,12 @@ export default class LuaModule { this.luaL_addvalue = this.cwrap('luaL_addvalue', null, ['number']) this.luaL_pushresult = this.cwrap('luaL_pushresult', null, ['number']) this.luaL_pushresultsize = this.cwrap('luaL_pushresultsize', null, ['number', 'number']) - this.luaL_buffinitsize = this.cwrap('luaL_buffinitsize', 'string', [ - 'number', - 'number', - 'number', - ]) + this.luaL_buffinitsize = this.cwrap('luaL_buffinitsize', 'string', ['number', 'number', 'number']) this.lua_newstate = this.cwrap('lua_newstate', 'number', ['number', 'number', 'number']) this.lua_close = this.cwrap('lua_close', null, ['number']) this.lua_newthread = this.cwrap('lua_newthread', 'number', ['number']) this.lua_closethread = this.cwrap('lua_closethread', 'number', ['number', 'number']) - this.lua_resetthread = L => this.lua_closethread(L, null) + this.lua_resetthread = (L) => this.lua_closethread(L, null) this.lua_atpanic = this.cwrap('lua_atpanic', 'number', ['number', 'number']) this.lua_version = this.cwrap('lua_version', 'number', ['number']) this.lua_absindex = this.cwrap('lua_absindex', 'number', ['number', 'number']) @@ -524,20 +397,11 @@ export default class LuaModule { this.lua_topointer = this.cwrap('lua_topointer', 'number', ['number', 'number']) this.lua_arith = this.cwrap('lua_arith', null, ['number', 'number']) this.lua_rawequal = this.cwrap('lua_rawequal', 'number', ['number', 'number', 'number']) - this.lua_compare = this.cwrap('lua_compare', 'number', [ - 'number', - 'number', - 'number', - 'number', - ]) + this.lua_compare = this.cwrap('lua_compare', 'number', ['number', 'number', 'number', 'number']) this.lua_pushnil = this.cwrap('lua_pushnil', null, ['number']) this.lua_pushnumber = this.cwrap('lua_pushnumber', null, ['number', 'number']) this.lua_pushinteger = this.cwrap('lua_pushinteger', null, ['number', 'number']) - this.lua_pushlstring = this.cwrap('lua_pushlstring', 'string', [ - 'number', - 'string|number', - 'number', - ]) + this.lua_pushlstring = this.cwrap('lua_pushlstring', 'string', ['number', 'string|number', 'number']) this.lua_pushstring = this.cwrap('lua_pushstring', 'string', ['number', 'string|number']) this.lua_pushcclosure = this.cwrap('lua_pushcclosure', null, ['number', 'number', 'number']) this.lua_pushboolean = this.cwrap('lua_pushboolean', null, ['number', 'number']) @@ -551,17 +415,9 @@ export default class LuaModule { this.lua_rawgeti = this.cwrap('lua_rawgeti', 'number', ['number', 'number', 'number']) this.lua_rawgetp = this.cwrap('lua_rawgetp', 'number', ['number', 'number', 'number']) this.lua_createtable = this.cwrap('lua_createtable', null, ['number', 'number', 'number']) - this.lua_newuserdatauv = this.cwrap('lua_newuserdatauv', 'number', [ - 'number', - 'number', - 'number', - ]) + this.lua_newuserdatauv = this.cwrap('lua_newuserdatauv', 'number', ['number', 'number', 'number']) this.lua_getmetatable = this.cwrap('lua_getmetatable', 'number', ['number', 'number']) - this.lua_getiuservalue = this.cwrap('lua_getiuservalue', 'number', [ - 'number', - 'number', - 'number', - ]) + this.lua_getiuservalue = this.cwrap('lua_getiuservalue', 'number', ['number', 'number', 'number']) this.lua_setglobal = this.cwrap('lua_setglobal', null, ['number', 'string']) this.lua_settable = this.cwrap('lua_settable', null, ['number', 'number']) this.lua_setfield = this.cwrap('lua_setfield', null, ['number', 'number', 'string']) @@ -570,46 +426,13 @@ export default class LuaModule { this.lua_rawseti = this.cwrap('lua_rawseti', null, ['number', 'number', 'number']) this.lua_rawsetp = this.cwrap('lua_rawsetp', null, ['number', 'number', 'number']) this.lua_setmetatable = this.cwrap('lua_setmetatable', 'number', ['number', 'number']) - this.lua_setiuservalue = this.cwrap('lua_setiuservalue', 'number', [ - 'number', - 'number', - 'number', - ]) - this.lua_callk = this.cwrap('lua_callk', null, [ - 'number', - 'number', - 'number', - 'number', - 'number', - ]) - this.lua_pcallk = this.cwrap('lua_pcallk', 'number', [ - 'number', - 'number', - 'number', - 'number', - 'number', - 'number', - ]) - this.lua_load = this.cwrap('lua_load', 'number', [ - 'number', - 'number', - 'number', - 'string', - 'string', - ]) + this.lua_setiuservalue = this.cwrap('lua_setiuservalue', 'number', ['number', 'number', 'number']) + this.lua_callk = this.cwrap('lua_callk', null, ['number', 'number', 'number', 'number', 'number']) + this.lua_pcallk = this.cwrap('lua_pcallk', 'number', ['number', 'number', 'number', 'number', 'number', 'number']) + this.lua_load = this.cwrap('lua_load', 'number', ['number', 'number', 'number', 'string', 'string']) this.lua_dump = this.cwrap('lua_dump', 'number', ['number', 'number', 'number', 'number']) - this.lua_yieldk = this.cwrap('lua_yieldk', 'number', [ - 'number', - 'number', - 'number', - 'number', - ]) - this.lua_resume = this.cwrap('lua_resume', 'number', [ - 'number', - 'number', - 'number', - 'number', - ]) + this.lua_yieldk = this.cwrap('lua_yieldk', 'number', ['number', 'number', 'number', 'number']) + this.lua_resume = this.cwrap('lua_resume', 'number', ['number', 'number', 'number', 'number']) this.lua_status = this.cwrap('lua_status', 'number', ['number']) this.lua_isyieldable = this.cwrap('lua_isyieldable', 'number', ['number']) this.lua_setwarnf = this.cwrap('lua_setwarnf', null, ['number', 'number', 'number']) @@ -630,13 +453,7 @@ export default class LuaModule { this.lua_getupvalue = this.cwrap('lua_getupvalue', 'string', ['number', 'number', 'number']) this.lua_setupvalue = this.cwrap('lua_setupvalue', 'string', ['number', 'number', 'number']) this.lua_upvalueid = this.cwrap('lua_upvalueid', 'number', ['number', 'number', 'number']) - this.lua_upvaluejoin = this.cwrap('lua_upvaluejoin', null, [ - 'number', - 'number', - 'number', - 'number', - 'number', - ]) + this.lua_upvaluejoin = this.cwrap('lua_upvaluejoin', null, ['number', 'number', 'number', 'number', 'number']) this.lua_sethook = this.cwrap('lua_sethook', null, ['number', 'number', 'number', 'number']) this.lua_gethook = this.cwrap('lua_gethook', 'number', ['number']) this.lua_gethookmask = this.cwrap('lua_gethookmask', 'number', ['number']) @@ -653,7 +470,7 @@ export default class LuaModule { this.luaopen_math = this.cwrap('luaopen_math', 'number', ['number']) this.luaopen_debug = this.cwrap('luaopen_debug', 'number', ['number']) this.luaopen_package = this.cwrap('luaopen_package', 'number', ['number']) - this.luaL_openlibs = L => this.luaL_openselectedlibs(L, -1, 0) + this.luaL_openlibs = (L) => this.luaL_openselectedlibs(L, -1, 0) } public lua_remove(luaState: LuaState, index: number): void { @@ -739,15 +556,10 @@ export default class LuaModule { argTypes: Array, ): (...args: any[]) => any { // optimization for common case - const hasStringOrNumber = argTypes.some(argType => argType === 'string|number') + const hasStringOrNumber = argTypes.some((argType) => argType === 'string|number') if (!hasStringOrNumber) { return (...args: any[]) => - this._emscripten.ccall( - name, - returnType, - argTypes as Emscripten.JSType[], - args as Emscripten.TypeCompatibleWithC[], - ) + this._emscripten.ccall(name, returnType, argTypes as Emscripten.JSType[], args as Emscripten.TypeCompatibleWithC[]) } return (...args: any[]) => { @@ -759,9 +571,7 @@ export default class LuaModule { } else { // because it will be freed later, this can only be used on functions that lua internally copies the string if (args[i]?.length > 1024) { - const bufferPointer = this._emscripten.stringToNewUTF8( - args[i] as string, - ) + const bufferPointer = this._emscripten.stringToNewUTF8(args[i] as string) args[i] = bufferPointer pointersToBeFreed.push(bufferPointer) return 'number' @@ -774,12 +584,7 @@ export default class LuaModule { }) try { - return this._emscripten.ccall( - name, - returnType, - resolvedArgTypes, - args as Emscripten.TypeCompatibleWithC[], - ) + return this._emscripten.ccall(name, returnType, resolvedArgTypes, args as Emscripten.TypeCompatibleWithC[]) } finally { for (const pointer of pointersToBeFreed) { this._emscripten._free(pointer) @@ -789,9 +594,7 @@ export default class LuaModule { } } -function createOutputWriter( - writer?: (content: string) => void, -): ((charCode: number | null) => void) | null { +function createOutputWriter(writer?: (content: string) => void): ((charCode: number | null) => void) | null { if (!writer) { return null } diff --git a/test/browser.test.js b/test/browser.test.js index da0f295..1328803 100644 --- a/test/browser.test.js +++ b/test/browser.test.js @@ -82,8 +82,8 @@ describe('Browser environment', () => { await page.goto(`http://127.0.0.1:${port}/`) await page.waitForFunction(() => window.__ready === true, null, { timeout: 15_000 }) - const result = await page.evaluate(async (code) => { - return await window.__runTest(code) + const result = await page.evaluate(async (c) => { + return await window.__runTest(c) }, code) if (errors.length > 0) { diff --git a/test/nodefs.test.js b/test/nodefs.test.js index 166530f..27f5d9d 100644 --- a/test/nodefs.test.js +++ b/test/nodefs.test.js @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, symlinkSync, readdirSync } from 'node:fs' +import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { expect } from 'chai' From c66b3e32e48234b04686a794ff56054b82803ce2 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Tue, 24 Mar 2026 22:02:15 -0300 Subject: [PATCH 36/52] ci: install Playwright browsers before running tests The browser tests require Playwright's Chromium binary which isn't available in the CI runner by default. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 54fedfb..06a732e 100755 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,5 +27,6 @@ jobs: - run: npm run lint:nofix - run: npm run build:wasm - run: npm run build + - run: npx playwright install --with-deps chromium - run: npm test - run: npm run luatests From 335320efc0dd18a9876978cb399bab7519430a32 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Sat, 25 Apr 2026 13:14:11 -0300 Subject: [PATCH 37/52] fix: keep new threads off of stack --- src/engine.ts | 12 ++++++------ test/engine.test.js | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/engine.ts b/src/engine.ts index 440e91d..bed0adf 100755 --- a/src/engine.ts +++ b/src/engine.ts @@ -1,4 +1,4 @@ -import { CreateEngineOptions } from './types' +import { CreateEngineOptions, LUA_REGISTRYINDEX } from './types' import Global from './global' import type LuaModule from './module' import Thread from './thread' @@ -62,7 +62,7 @@ export default class LuaEngine { * @returns A Promise that resolves to the result returned by the Lua script execution. */ public doString(script: string): Promise { - return this.callByteCode((thread) => thread.loadString(script)) + return this.callByteCode(thread => thread.loadString(script)) } /** @@ -71,7 +71,7 @@ export default class LuaEngine { * @returns - A Promise that resolves to the result returned by the Lua script execution. */ public doFile(filename: string): Promise { - return this.callByteCode((thread) => thread.loadFile(filename)) + return this.callByteCode(thread => thread.loadFile(filename)) } /** @@ -99,7 +99,8 @@ export default class LuaEngine { // WARNING: It will not wait for open handles and can potentially cause bugs if JS code tries to reference Lua after executed private async callByteCode(loader: (thread: Thread) => void): Promise { const thread = this.global.newThread() - const threadIndex = this.global.getTop() + // Move the thread off the global stack and into the registry as a GC anchor so it doesn't pile threads up into the stack + const ref = this.module.luaL_ref(this.global.address, LUA_REGISTRYINDEX) try { loader(thread) const result = await thread.run(0) @@ -111,8 +112,7 @@ export default class LuaEngine { } return undefined } finally { - // Pop the read on success or failure - this.global.remove(threadIndex) + this.module.luaL_unref(this.global.address, LUA_REGISTRYINDEX, ref) } } } diff --git a/test/engine.test.js b/test/engine.test.js index 05fbcfb..1bd7789 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -834,4 +834,20 @@ describe('State', () => { expect(result).to.equal(a + b) } }) + + it('many concurrent doString calls should succeed', async function () { + this.timeout(15000) + const state = await getState() + const length = 55 + + const promises = [] + for (let i = 0; i < length; i++) { + promises.push(state.doString(`return ${i}`)) + } + const results = await Promise.all(promises) + + for (let i = 0; i < length; i++) { + expect(results[i]).to.equal(i) + } + }) }) From 5c5dee5bb498372a0d8d1e43600607fe570cd647 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Sat, 25 Apr 2026 14:30:09 -0300 Subject: [PATCH 38/52] cleanup --- package-lock.json | 539 +++++++++++++++----------------- package.json | 14 +- src/engine.ts | 4 +- src/thread.ts | 3 +- src/type-extensions/error.ts | 2 +- src/type-extensions/function.ts | 2 - test/browser.test.js | 10 +- test/engine.test.js | 26 +- 8 files changed, 293 insertions(+), 307 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1c200d0..6c89b92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,46 +15,23 @@ "wasmoon": "bin/wasmoon" }, "devDependencies": { - "@types/node": "25.5.0", + "@types/node": "*", "chai": "6.2.2", "chai-as-promised": "8.0.2", "fengari": "0.1.5", "mocha": "11.7.5", - "oxfmt": "0.42.0", - "oxlint": "1.57.0", - "playwright": "1.58.2", - "rolldown": "1.0.0-rc.11", + "oxfmt": "0.46.0", + "oxlint": "1.61.0", + "playwright": "1.59.1", + "rolldown": "1.0.0-rc.17", "tslib": "2.8.1", - "typescript": "6.0.2" - } - }, - "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "typescript": "6.0.3" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -81,26 +58,28 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", "dev": true, "license": "MIT", "funding": { @@ -108,9 +87,9 @@ } }, "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.42.0.tgz", - "integrity": "sha512-dsqPTYsozeokRjlrt/b4E7Pj0z3eS3Eg74TWQuuKbjY4VttBmA88rB7d50Xrd+TZ986qdXCNeZRPEzZHAe+jow==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.46.0.tgz", + "integrity": "sha512-b1doV4WRcJU+BESSlCvCjV+5CEr/T6h0frArAdV26Nir+gGNFNaylvDiiMPfF1pxeV0txZEs38ojzJaxBYg+ng==", "cpu": [ "arm" ], @@ -125,9 +104,9 @@ } }, "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.42.0.tgz", - "integrity": "sha512-t+aAjHxcr5eOBphFHdg1ouQU9qmZZoRxnX7UOJSaTwSoKsb6TYezNKO0YbWytGXCECObRqNcUxPoPr0KaraAIg==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.46.0.tgz", + "integrity": "sha512-v6+HhjsoV3GO0u2u9jLSAZrvWfTraDxKofUIQ7/ktS7tzS+epVsxdHmeM+XxuNcAY/nWxxU1Sg4JcGTNRXraBA==", "cpu": [ "arm64" ], @@ -142,9 +121,9 @@ } }, "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.42.0.tgz", - "integrity": "sha512-ulpSEYMKg61C5bRMZinFHrKJYRoKGVbvMEXA5zM1puX3O9T6Q4XXDbft20yrDijpYWeuG59z3Nabt+npeTsM1A==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.46.0.tgz", + "integrity": "sha512-3eeooJGrqGIlI5MyryDZsAcKXSmKIgAD4yYtfRrRJzXZ0UTFZtiSveIur56YPrGMYZwT4XyVhHsMqrNwr1XeFA==", "cpu": [ "arm64" ], @@ -159,9 +138,9 @@ } }, "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.42.0.tgz", - "integrity": "sha512-ttxLKhQYPdFiM8I/Ri37cvqChE4Xa562nNOsZFcv1CKTVLeEozXjKuYClNvxkXmNlcF55nzM80P+CQkdFBu+uQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.46.0.tgz", + "integrity": "sha512-QG8BDM0CXWbu84k2SKmCqfEddPQPFiBicwtYnLqHRWZZl57HbtOLRMac/KTq2NO4AEc4ICCBpFxJIV9zcqYfkQ==", "cpu": [ "x64" ], @@ -176,9 +155,9 @@ } }, "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.42.0.tgz", - "integrity": "sha512-Og7QS3yI3tdIKYZ58SXik0rADxIk2jmd+/YvuHRyKULWpG4V2fR5V4hvKm624Mc0cQET35waPXiCQWvjQEjwYQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.46.0.tgz", + "integrity": "sha512-9DdCqS/n2ncu/Chazvt3cpgAjAmIGQDz7hFKSrNItMApyV/Ja9mz3hD4JakIE3nS8PW9smEbPWnb389QLBY4nw==", "cpu": [ "x64" ], @@ -193,9 +172,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.42.0.tgz", - "integrity": "sha512-jwLOw/3CW4H6Vxcry4/buQHk7zm9Ne2YsidzTL1kpiMe4qqrRCwev3dkyWe2YkFmP+iZCQ7zku4KwjcLRoh8ew==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.46.0.tgz", + "integrity": "sha512-Dgs7VeE2jT0LHMhw6tPEt0xQYe54kBqHEovmWsv4FVQlegCOvlIJNx0S8n4vj8WUtpT+Z6BD2HhKJPLglLxvZg==", "cpu": [ "arm" ], @@ -210,9 +189,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.42.0.tgz", - "integrity": "sha512-XwXu2vkMtiq2h7tfvN+WA/9/5/1IoGAVCFPiiQUvcAuG3efR97KNcRGM8BetmbYouFotQ2bDal3yyjUx6IPsTg==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.46.0.tgz", + "integrity": "sha512-Zxn3adhTH13JKnU4xXJj8FeEfF680XjXh3gSShKl57HCMBRde2tUJTgogV/1MSHA80PJEVrDa7r66TLVq3Ia7Q==", "cpu": [ "arm" ], @@ -227,9 +206,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.42.0.tgz", - "integrity": "sha512-ea7s/XUJoT7ENAtUQDudFe3nkSM3e3Qpz4nJFRdzO2wbgXEcjnchKLEsV3+t4ev3r8nWxIYr9NRjPWtnyIFJVA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.46.0.tgz", + "integrity": "sha512-+TWipjrgVM8D7aIdDD0tlr3teLTTvQTn7QTE5BpT10H1Fj82gfdn9X6nn2sDgx/MepuSCfSnzFNJq2paLL0OiA==", "cpu": [ "arm64" ], @@ -244,9 +223,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.42.0.tgz", - "integrity": "sha512-+JA0YMlSdDqmacygGi2REp57c3fN+tzARD8nwsukx9pkCHK+6DkbAA9ojS4lNKsiBjIW8WWa0pBrBWhdZEqfuw==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.46.0.tgz", + "integrity": "sha512-aAUPBWJ1lGwwnxZUEDLJ94+Iy6MuwJwPxUgO4sCA5mEEyDk7b+cDQ+JpX1VR150Zoyd+D49gsrUzpUK5h587Eg==", "cpu": [ "arm64" ], @@ -261,9 +240,9 @@ } }, "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.42.0.tgz", - "integrity": "sha512-VfnET0j4Y5mdfCzh5gBt0NK28lgn5DKx+8WgSMLYYeSooHhohdbzwAStLki9pNuGy51y4I7IoW8bqwAaCMiJQg==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.46.0.tgz", + "integrity": "sha512-ufBCJukyFX/UDrokP/r6BGDoTInnsDs7bxyzKAgMiZlt2Qu8GPJSJ6Zm6whIiJzKk0naxA8ilwmbO1LMw6Htxw==", "cpu": [ "ppc64" ], @@ -278,9 +257,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.42.0.tgz", - "integrity": "sha512-gVlCbmBkB0fxBWbhBj9rcxezPydsQHf4MFKeHoTSPicOQ+8oGeTQgQ8EeesSybWeiFPVRx3bgdt4IJnH6nOjAA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.46.0.tgz", + "integrity": "sha512-eqtlC2YmPqjun76R1gVfGLuKWx7NuEnLEAudZ7n6ipSKbCZTqIKSs1b5Y8K/JHZsRpLkeSmAAjig5HOIg8fQzQ==", "cpu": [ "riscv64" ], @@ -295,9 +274,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.42.0.tgz", - "integrity": "sha512-zN5OfstL0avgt/IgvRu0zjQzVh/EPkcLzs33E9LMAzpqlLWiPWeMDZyMGFlSRGOdDjuNmlZBCgj0pFnK5u32TQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.46.0.tgz", + "integrity": "sha512-yccVOO2nMXkQLGgy0He3EQEwKD7NF0zEk+/OWmroznkqXyJdN6bfK0LtNnr6/14Bh3FjpYq7bP33l/VloCnxpA==", "cpu": [ "riscv64" ], @@ -312,9 +291,9 @@ } }, "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.42.0.tgz", - "integrity": "sha512-9X6+H2L0qMc2sCAgO9HS03bkGLMKvOFjmEdchaFlany3vNZOjnVui//D8k/xZAtQv2vaCs1reD5KAgPoIU4msA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.46.0.tgz", + "integrity": "sha512-aAf7fG23OQCey6VRPj9IeCraoYtpgtx0ZyJ1CXkPyT1wjzBE7c3xtuxHe/AdHaJfVVb/SXpSk8Gl1LzyQupSqw==", "cpu": [ "s390x" ], @@ -329,9 +308,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.42.0.tgz", - "integrity": "sha512-BajxJ6KQvMMdpXGPWhBGyjb2Jvx4uec0w+wi6TJZ6Tv7+MzPwe0pO8g5h1U0jyFgoaF7mDl6yKPW3ykWcbUJRw==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.46.0.tgz", + "integrity": "sha512-q0JPsTMyJNjYrBvYFDz4WbVsafNZaPCZv4RnFypRotLqpKROtBZcEaXQW4eb9YmvLU3NckVemLJnzkSZSdmOxw==", "cpu": [ "x64" ], @@ -346,9 +325,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.42.0.tgz", - "integrity": "sha512-0wV284I6vc5f0AqAhgAbHU2935B4bVpncPoe5n/WzVZY/KnHgqxC8iSFGeSyLWEgstFboIcWkOPck7tqbdHkzA==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.46.0.tgz", + "integrity": "sha512-7LsLY9Cw57GPkhSR+duI3mt9baRczK/DtHYSldQ4BEU92da9igBQNl4z7Vq5U9NNPsh1FmpKvv1q9WDtiUQR1A==", "cpu": [ "x64" ], @@ -363,9 +342,9 @@ } }, "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.42.0.tgz", - "integrity": "sha512-p4BG6HpGnhfgHk1rzZfyR6zcWkE7iLrWxyehHfXUy4Qa5j3e0roglFOdP/Nj5cJJ58MA3isQ5dlfkW2nNEpolw==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.46.0.tgz", + "integrity": "sha512-lHiBOz8Duaku7JtRNLlps3j++eOaICPZSd8FCVmTDM4DFOPT71Bjn7g6iar1z7StXlKRweUKxWUs4sA+zWGDXg==", "cpu": [ "arm64" ], @@ -380,9 +359,9 @@ } }, "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.42.0.tgz", - "integrity": "sha512-mn//WV60A+IetORDxYieYGAoQso4KnVRRjORDewMcod4irlRe0OSC7YPhhwaexYNPQz/GCFk+v9iUcZ2W22yxQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.46.0.tgz", + "integrity": "sha512-/5ktYUliP89RhgC37DBH1x20U5zPSZMy3cMEcO0j3793rbHP9MWsknBwQB6eozRzWmYrh0IFM/p20EbPvDlYlg==", "cpu": [ "arm64" ], @@ -397,9 +376,9 @@ } }, "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.42.0.tgz", - "integrity": "sha512-3gWltUrvuz4LPJXWivoAxZ28Of2O4N7OGuM5/X3ubPXCEV8hmgECLZzjz7UYvSDUS3grfdccQwmjynm+51EFpw==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.46.0.tgz", + "integrity": "sha512-3WTnoiuIr8XvV0DIY7SN+1uJSwKf4sPpcbHfobcRT9JutGcLaef/miyBB87jxd3aqH+mS0+G5lsgHuXLUwjjpQ==", "cpu": [ "ia32" ], @@ -414,9 +393,9 @@ } }, "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.42.0.tgz", - "integrity": "sha512-Wg4TMAfQRL9J9AZevJ/ZNy3uyyDztDYQtGr4P8UyyzIhLhFrdSmz1J/9JT+rv0fiCDLaFOBQnj3f3K3+a5PzDQ==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.46.0.tgz", + "integrity": "sha512-IXxiQpkYnOwNfP23vzwSfhdpxJzyiPTY7eTn6dn3DsriKddESzM8i6kfq9R7CD/PUJwCvQT22NgtygBeug3KoA==", "cpu": [ "x64" ], @@ -431,9 +410,9 @@ } }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.57.0.tgz", - "integrity": "sha512-C7EiyfAJG4B70496eV543nKiq5cH0o/xIh/ufbjQz3SIvHhlDDsyn+mRFh+aW8KskTyUpyH2LGWL8p2oN6bl1A==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.61.0.tgz", + "integrity": "sha512-6eZBPgiigK5txqoVgRqxbaxiom4lM8AP8CyKPPvpzKnQ3iFRFOIDc+0AapF+qsUSwjOzr5SGk4SxQDpQhkSJMQ==", "cpu": [ "arm" ], @@ -448,9 +427,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.57.0.tgz", - "integrity": "sha512-9i80AresjZ/FZf5xK8tKFbhQnijD4s1eOZw6/FHUwD59HEZbVLRc2C88ADYJfLZrF5XofWDiRX/Ja9KefCLy7w==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.61.0.tgz", + "integrity": "sha512-CkwLR69MUnyv5wjzebvbbtTSUwqLxM35CXE79bHqDIK+NtKmPEUpStTcLQRZMCo4MP0qRT6TXIQVpK0ZVScnMA==", "cpu": [ "arm64" ], @@ -465,9 +444,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.57.0.tgz", - "integrity": "sha512-0eUfhRz5L2yKa9I8k3qpyl37XK3oBS5BvrgdVIx599WZK63P8sMbg+0s4IuxmIiZuBK68Ek+Z+gcKgeYf0otsg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.61.0.tgz", + "integrity": "sha512-8JbefTkbmvqkqWjmQrHke+MdpgT2UghhD/ktM4FOQSpGeCgbMToJEKdl9zwhr/YWTl92i4QI1KiTwVExpcUN8A==", "cpu": [ "arm64" ], @@ -482,9 +461,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.57.0.tgz", - "integrity": "sha512-UvrSuzBaYOue+QMAcuDITe0k/Vhj6KZGjfnI6x+NkxBTke/VoM7ZisaxgNY0LWuBkTnd1OmeQfEQdQ48fRjkQg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.61.0.tgz", + "integrity": "sha512-uWpoxDT47hTnDLcdEh5jVbso8rlTTu5o0zuqa9J8E0JAKmIWn7kGFEIB03Pycn2hd2vKxybPGLhjURy/9We5FQ==", "cpu": [ "x64" ], @@ -499,9 +478,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.57.0.tgz", - "integrity": "sha512-wtQq0dCoiw4bUwlsNVDJJ3pxJA218fOezpgtLKrbQqUtQJcM9yP8z+I9fu14aHg0uyAxIY+99toL6uBa2r7nxA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.61.0.tgz", + "integrity": "sha512-K/o4hEyW7flfMel0iBVznmMBt7VIMHGdjADocHKpK1DUF9erpWnJ+BSSWd2W0c8K3mPtpph+CuHzRU6CI3l9jQ==", "cpu": [ "x64" ], @@ -516,9 +495,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.57.0.tgz", - "integrity": "sha512-qxFWl2BBBFcT4djKa+OtMdnLgoHEJXpqjyGwz8OhW35ImoCwR5qtAGqApNYce5260FQqoAHW8S8eZTjiX67Tsg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.61.0.tgz", + "integrity": "sha512-P6040ZkcyweJ0Po9yEFqJCdvZnf3VNCGs1SIHgXDf8AAQNC6ID/heXQs9iSgo2FH7gKaKq32VWc59XZwL34C5Q==", "cpu": [ "arm" ], @@ -533,9 +512,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.57.0.tgz", - "integrity": "sha512-SQoIsBU7J0bDW15/f0/RvxHfY3Y0+eB/caKBQtNFbuerTiA6JCYx9P1MrrFTwY2dTm/lMgTSgskvCEYk2AtG/Q==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.61.0.tgz", + "integrity": "sha512-bwxrGCzTZkuB+THv2TQ1aTkVEfv5oz8sl+0XZZCpoYzErJD8OhPQOTA0ENPd1zJz8QsVdSzSrS2umKtPq4/JXg==", "cpu": [ "arm" ], @@ -550,9 +529,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.57.0.tgz", - "integrity": "sha512-jqxYd1W6WMeozsCmqe9Rzbu3SRrGTyGDAipRlRggetyYbUksJqJKvUNTQtZR/KFoJPb+grnSm5SHhdWrywv3RQ==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.61.0.tgz", + "integrity": "sha512-vkhb9/wKguMkLlrm3FoJW/Xmdv31GgYAE+x8lxxQ+7HeOxXUySI0q36a3NTVIuQUdLzxCI1zzMGsk1o37FOe3w==", "cpu": [ "arm64" ], @@ -567,9 +546,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.57.0.tgz", - "integrity": "sha512-i66WyEPVEvq9bxRUCJ/MP5EBfnTDN3nhwEdFZFTO5MmLLvzngfWEG3NSdXQzTT3vk5B9i6C2XSIYBh+aG6uqyg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.61.0.tgz", + "integrity": "sha512-bl1dQh8LnVqsj6oOQAcxwbuOmNJkwc4p6o//HTBZhNTzJy21TLDwAviMqUFNUxDHkPGpmdKTSN4tWTjLryP8xg==", "cpu": [ "arm64" ], @@ -584,9 +563,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.57.0.tgz", - "integrity": "sha512-oMZDCwz4NobclZU3pH+V1/upVlJZiZvne4jQP+zhJwt+lmio4XXr4qG47CehvrW1Lx2YZiIHuxM2D4YpkG3KVA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.61.0.tgz", + "integrity": "sha512-QoOX6KB2IiEpyOj/HKqaxi+NQHPnOgNgnr22n9N4ANJCzXkUlj1UmeAbFb4PpqdlHIzvGDM5xZ0OKtcLq9RhiQ==", "cpu": [ "ppc64" ], @@ -601,9 +580,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.57.0.tgz", - "integrity": "sha512-uoBnjJ3MMEBbfnWC1jSFr7/nSCkcQYa72NYoNtLl1imshDnWSolYCjzb8LVCwYCCfLJXD+0gBLD7fyC14c0+0g==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.61.0.tgz", + "integrity": "sha512-1TGcTerjY6p152wCof3oKElccq3xHljS/Mucp04gV/4ATpP6nO7YNnp7opEg6SHkv2a57/b4b8Ndm9znJ1/qAw==", "cpu": [ "riscv64" ], @@ -618,9 +597,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.57.0.tgz", - "integrity": "sha512-BdrwD7haPZ8a9KrZhKJRSj6jwCor+Z8tHFZ3PT89Y3Jq5v3LfMfEePeAmD0LOTWpiTmzSzdmyw9ijneapiVHKQ==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.61.0.tgz", + "integrity": "sha512-65wXEmZIrX2ADwC8i/qFL4EWLSbeuBpAm3suuX1vu4IQkKd+wLT/HU/BOl84kp91u2SxPkPDyQgu4yrqp8vwVA==", "cpu": [ "riscv64" ], @@ -635,9 +614,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.57.0.tgz", - "integrity": "sha512-BNs+7ZNsRstVg2tpNxAXfMX/Iv5oZh204dVyb8Z37+/gCh+yZqNTlg6YwCLIMPSk5wLWIGOaQjT0GUOahKYImw==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.61.0.tgz", + "integrity": "sha512-TVvhgMvor7Qa6COeXxCJ7ENOM+lcAOGsQ0iUdPSCv2hxb9qSHLQ4XF1h50S6RE1gBOJ0WV3rNukg4JJJP1LWRA==", "cpu": [ "s390x" ], @@ -652,9 +631,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.57.0.tgz", - "integrity": "sha512-AghS18w+XcENcAX0+BQGLiqjpqpaxKJa4cWWP0OWNLacs27vHBxu7TYkv9LUSGe5w8lOJHeMxcYfZNOAPqw2bg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.61.0.tgz", + "integrity": "sha512-SjpS5uYuFoDnDdZPwZE59ndF95AsY47R5MliuneTWR1pDm2CxGJaYXbKULI71t5TVfLQUWmrHEGRL9xvuq6dnA==", "cpu": [ "x64" ], @@ -669,9 +648,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.57.0.tgz", - "integrity": "sha512-E/FV3GB8phu/Rpkhz5T96hAiJlGzn91qX5yj5gU754P5cmVGXY1Jw/VSjDSlZBCY3VHjsVLdzgdkJaomEmcNOg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.61.0.tgz", + "integrity": "sha512-gGfAeGD4sNJGILZbc/yKcIimO9wQnPMoYp9swAaKeEtwsSQAbU+rsdQze5SBtIP6j0QDzeYd4XSSUCRCF+LIeQ==", "cpu": [ "x64" ], @@ -686,9 +665,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.57.0.tgz", - "integrity": "sha512-xvZ2yZt0nUVfU14iuGv3V25jpr9pov5N0Wr28RXnHFxHCRxNDMtYPHV61gGLhN9IlXM96gI4pyYpLSJC5ClLCQ==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.61.0.tgz", + "integrity": "sha512-OlVT0LrG/ct33EVtWRyR+B/othwmDWeRxfi13wUdPeb3lAT5TgTcFDcfLfarZtzB4W1nWF/zICMgYdkggX2WmQ==", "cpu": [ "arm64" ], @@ -703,9 +682,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.57.0.tgz", - "integrity": "sha512-Z4D8Pd0AyHBKeazhdIXeUUy5sIS3Mo0veOlzlDECg6PhRRKgEsBJCCV1n+keUZtQ04OP+i7+itS3kOykUyNhDg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.61.0.tgz", + "integrity": "sha512-vI//NZPJk6DToiovPtaiwD4iQ7kO1r5ReWQD0sOOyKRtP3E2f6jxin4uvwi3OvDzHA2EFfd7DcZl5dtkQh7g1w==", "cpu": [ "arm64" ], @@ -720,9 +699,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.57.0.tgz", - "integrity": "sha512-StOZ9nFMVKvevicbQfql6Pouu9pgbeQnu60Fvhz2S6yfMaii+wnueLnqQ5I1JPgNF0Syew4voBlAaHD13wH6tw==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.61.0.tgz", + "integrity": "sha512-0ySj4/4zd2XjePs3XAQq7IigIstN4LPQZgCyigX5/ERMLjdWAJfnxcTsrtxZxuij8guJW8foXuHmhGxW0H4dDA==", "cpu": [ "ia32" ], @@ -737,9 +716,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.57.0.tgz", - "integrity": "sha512-6PuxhYgth8TuW0+ABPOIkGdBYw+qYGxgIdXPHSVpiCDm+hqTTWCmC739St1Xni0DJBt8HnSHTG67i1y6gr8qrA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.61.0.tgz", + "integrity": "sha512-0xgSiyeqDLDZxXoe9CVJrOx3TUVsfyoOY7cNi03JbItNcC9WCZqrSNdrAbHONxhSPaVh/lzfnDcON1RqSUMhHw==", "cpu": [ "x64" ], @@ -765,9 +744,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", "cpu": [ "arm64" ], @@ -782,9 +761,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", "cpu": [ "arm64" ], @@ -799,9 +778,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", - "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", "cpu": [ "x64" ], @@ -816,9 +795,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", - "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", "cpu": [ "x64" ], @@ -833,9 +812,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", - "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", "cpu": [ "arm" ], @@ -850,9 +829,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", "cpu": [ "arm64" ], @@ -867,9 +846,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", - "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", "cpu": [ "arm64" ], @@ -884,9 +863,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", "cpu": [ "ppc64" ], @@ -901,9 +880,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", "cpu": [ "s390x" ], @@ -918,9 +897,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", "cpu": [ "x64" ], @@ -935,9 +914,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", - "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", "cpu": [ "x64" ], @@ -952,9 +931,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", "cpu": [ "arm64" ], @@ -969,9 +948,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", - "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", "cpu": [ "wasm32" ], @@ -979,16 +958,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", - "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", "cpu": [ "arm64" ], @@ -1003,9 +984,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", - "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", "cpu": [ "x64" ], @@ -1020,9 +1001,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", - "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", "dev": true, "license": "MIT" }, @@ -1712,9 +1693,9 @@ "license": "MIT" }, "node_modules/oxfmt": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.42.0.tgz", - "integrity": "sha512-QhejGErLSMReNuZ6vxgFHDyGoPbjTRNi6uGHjy0cvIjOQFqD6xmr/T+3L41ixR3NIgzcNiJ6ylQKpvShTgDfqg==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.46.0.tgz", + "integrity": "sha512-CopwJOwPAjZ9p76fCvz+mSOJTw9/NY3cSksZK3VO/bUQ8UoEcketNgUuYS0UB3p+R9XnXe7wGGXUmyFxc7QxJA==", "dev": true, "license": "MIT", "dependencies": { @@ -1730,31 +1711,31 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.42.0", - "@oxfmt/binding-android-arm64": "0.42.0", - "@oxfmt/binding-darwin-arm64": "0.42.0", - "@oxfmt/binding-darwin-x64": "0.42.0", - "@oxfmt/binding-freebsd-x64": "0.42.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.42.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.42.0", - "@oxfmt/binding-linux-arm64-gnu": "0.42.0", - "@oxfmt/binding-linux-arm64-musl": "0.42.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.42.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.42.0", - "@oxfmt/binding-linux-riscv64-musl": "0.42.0", - "@oxfmt/binding-linux-s390x-gnu": "0.42.0", - "@oxfmt/binding-linux-x64-gnu": "0.42.0", - "@oxfmt/binding-linux-x64-musl": "0.42.0", - "@oxfmt/binding-openharmony-arm64": "0.42.0", - "@oxfmt/binding-win32-arm64-msvc": "0.42.0", - "@oxfmt/binding-win32-ia32-msvc": "0.42.0", - "@oxfmt/binding-win32-x64-msvc": "0.42.0" + "@oxfmt/binding-android-arm-eabi": "0.46.0", + "@oxfmt/binding-android-arm64": "0.46.0", + "@oxfmt/binding-darwin-arm64": "0.46.0", + "@oxfmt/binding-darwin-x64": "0.46.0", + "@oxfmt/binding-freebsd-x64": "0.46.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.46.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.46.0", + "@oxfmt/binding-linux-arm64-gnu": "0.46.0", + "@oxfmt/binding-linux-arm64-musl": "0.46.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.46.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.46.0", + "@oxfmt/binding-linux-riscv64-musl": "0.46.0", + "@oxfmt/binding-linux-s390x-gnu": "0.46.0", + "@oxfmt/binding-linux-x64-gnu": "0.46.0", + "@oxfmt/binding-linux-x64-musl": "0.46.0", + "@oxfmt/binding-openharmony-arm64": "0.46.0", + "@oxfmt/binding-win32-arm64-msvc": "0.46.0", + "@oxfmt/binding-win32-ia32-msvc": "0.46.0", + "@oxfmt/binding-win32-x64-msvc": "0.46.0" } }, "node_modules/oxlint": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.57.0.tgz", - "integrity": "sha512-DGFsuBX5MFZX9yiDdtKjTrYPq45CZ8Fft6qCltJITYZxfwYjVdGf/6wycGYTACloauwIPxUnYhBVeZbHvleGhw==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.61.0.tgz", + "integrity": "sha512-ZC0ALuhDZ6ivOFG+sy0D0pEDN49EvsId98zVlmYdkcXHsEM14m/qTNUEsUpiFiCVbpIxYtVBmmLE87nsbUHohQ==", "dev": true, "license": "MIT", "bin": { @@ -1767,28 +1748,28 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.57.0", - "@oxlint/binding-android-arm64": "1.57.0", - "@oxlint/binding-darwin-arm64": "1.57.0", - "@oxlint/binding-darwin-x64": "1.57.0", - "@oxlint/binding-freebsd-x64": "1.57.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.57.0", - "@oxlint/binding-linux-arm-musleabihf": "1.57.0", - "@oxlint/binding-linux-arm64-gnu": "1.57.0", - "@oxlint/binding-linux-arm64-musl": "1.57.0", - "@oxlint/binding-linux-ppc64-gnu": "1.57.0", - "@oxlint/binding-linux-riscv64-gnu": "1.57.0", - "@oxlint/binding-linux-riscv64-musl": "1.57.0", - "@oxlint/binding-linux-s390x-gnu": "1.57.0", - "@oxlint/binding-linux-x64-gnu": "1.57.0", - "@oxlint/binding-linux-x64-musl": "1.57.0", - "@oxlint/binding-openharmony-arm64": "1.57.0", - "@oxlint/binding-win32-arm64-msvc": "1.57.0", - "@oxlint/binding-win32-ia32-msvc": "1.57.0", - "@oxlint/binding-win32-x64-msvc": "1.57.0" + "@oxlint/binding-android-arm-eabi": "1.61.0", + "@oxlint/binding-android-arm64": "1.61.0", + "@oxlint/binding-darwin-arm64": "1.61.0", + "@oxlint/binding-darwin-x64": "1.61.0", + "@oxlint/binding-freebsd-x64": "1.61.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.61.0", + "@oxlint/binding-linux-arm-musleabihf": "1.61.0", + "@oxlint/binding-linux-arm64-gnu": "1.61.0", + "@oxlint/binding-linux-arm64-musl": "1.61.0", + "@oxlint/binding-linux-ppc64-gnu": "1.61.0", + "@oxlint/binding-linux-riscv64-gnu": "1.61.0", + "@oxlint/binding-linux-riscv64-musl": "1.61.0", + "@oxlint/binding-linux-s390x-gnu": "1.61.0", + "@oxlint/binding-linux-x64-gnu": "1.61.0", + "@oxlint/binding-linux-x64-musl": "1.61.0", + "@oxlint/binding-openharmony-arm64": "1.61.0", + "@oxlint/binding-win32-arm64-msvc": "1.61.0", + "@oxlint/binding-win32-ia32-msvc": "1.61.0", + "@oxlint/binding-win32-x64-msvc": "1.61.0" }, "peerDependencies": { - "oxlint-tsgolint": ">=0.15.0" + "oxlint-tsgolint": ">=0.18.0" }, "peerDependenciesMeta": { "oxlint-tsgolint": { @@ -1880,13 +1861,13 @@ "license": "ISC" }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -1899,9 +1880,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1956,14 +1937,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", - "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.11" + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" @@ -1972,21 +1953,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.11", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", - "@rolldown/binding-darwin-x64": "1.0.0-rc.11", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" } }, "node_modules/safe-buffer": { @@ -2221,9 +2202,9 @@ "license": "0BSD" }, "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 6b951a3..0a9a7dd 100755 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:wasm": "node utils/build-wasm", "start": "rolldown -w -c", "bench": "npm run build && node bench/index.js", - "test": "mocha --parallel --require ./test/boot.js test/*.test.js", + "test": "mocha --parallel --exit --require ./test/boot.js test/*.test.js", "luatests": "node --experimental-import-meta-resolve test/luatests.js", "build": "rolldown -c && tsc -d --emitDeclarationOnly --rootDir src --declarationDir dist", "clean": "rm -rf dist build", @@ -41,17 +41,17 @@ "webassembly" ], "devDependencies": { - "@types/node": "25.5.0", + "@types/node": "*", "chai": "6.2.2", "chai-as-promised": "8.0.2", "fengari": "0.1.5", "mocha": "11.7.5", - "oxfmt": "0.42.0", - "oxlint": "1.57.0", - "rolldown": "1.0.0-rc.11", + "oxfmt": "0.46.0", + "oxlint": "1.61.0", + "rolldown": "1.0.0-rc.17", "tslib": "2.8.1", - "typescript": "6.0.2", - "playwright": "1.58.2" + "typescript": "6.0.3", + "playwright": "1.59.1" }, "dependencies": { "@types/emscripten": "1.41.5" diff --git a/src/engine.ts b/src/engine.ts index bed0adf..1ad3109 100755 --- a/src/engine.ts +++ b/src/engine.ts @@ -62,7 +62,7 @@ export default class LuaEngine { * @returns A Promise that resolves to the result returned by the Lua script execution. */ public doString(script: string): Promise { - return this.callByteCode(thread => thread.loadString(script)) + return this.callByteCode((thread) => thread.loadString(script)) } /** @@ -71,7 +71,7 @@ export default class LuaEngine { * @returns - A Promise that resolves to the result returned by the Lua script execution. */ public doFile(filename: string): Promise { - return this.callByteCode(thread => thread.loadFile(filename)) + return this.callByteCode((thread) => thread.loadFile(filename)) } /** diff --git a/src/thread.ts b/src/thread.ts index b158efe..98110c0 100755 --- a/src/thread.ts +++ b/src/thread.ts @@ -14,6 +14,7 @@ import { LuaType, PointerSize, } from './types' +import { isPromise } from './utils' export interface OrderedExtension { // Bigger is more important @@ -121,7 +122,7 @@ export default class Thread { this.pop(resumeResult.resultCount) // If there's a result and it's a promise, then wait for it. - if (lastValue === Promise.resolve(lastValue)) { + if (isPromise(lastValue)) { await lastValue } else { // If it's a non-promise, then skip a tick to yield for promises, timers, etc. diff --git a/src/type-extensions/error.ts b/src/type-extensions/error.ts index ac2ed93..745fe6c 100644 --- a/src/type-extensions/error.ts +++ b/src/type-extensions/error.ts @@ -51,7 +51,7 @@ class ErrorTypeExtension extends TypeExtension { thread.lua.lua_pop(thread.address, 1) if (injectObject) { - // Lastly create a static Promise constructor. + // Lastly create a static Error constructor. thread.set('Error', { create: (message: string | undefined) => { if (message && typeof message !== 'string') { diff --git a/src/type-extensions/function.ts b/src/type-extensions/function.ts index 9625653..cf46286 100644 --- a/src/type-extensions/function.ts +++ b/src/type-extensions/function.ts @@ -52,8 +52,6 @@ class FunctionTypeExtension extends TypeExtension { // Throws a lua error which does a jump if it does not match. - thread.lua.luaL_checkudata(calledL, 1, this.name) - const userDataPointer = thread.lua.luaL_checkudata(calledL, 1, this.name) const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') thread.lua.unref(referencePointer) diff --git a/test/browser.test.js b/test/browser.test.js index 1328803..2dd0afc 100644 --- a/test/browser.test.js +++ b/test/browser.test.js @@ -58,21 +58,15 @@ window.__ready = true } describe('Browser environment', () => { - let server, port, browser, context + let port, browser, context before(async function () { this.timeout(30_000) - ;({ server, port } = await startServer()) + ;({ port } = await startServer()) browser = await chromium.launch() context = await browser.newContext() }) - after(async () => { - await context?.close() - await browser?.close() - server?.close() - }) - async function runInBrowser(code) { const page = await context.newPage() const errors = [] diff --git a/test/engine.test.js b/test/engine.test.js index 1bd7789..639a8fd 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -22,7 +22,9 @@ class TestClass { describe('State', () => { let intervals = [] const setIntervalSafe = (callback, interval) => { - intervals.push(setInterval(() => callback(), interval)) + const handle = setInterval(() => callback(), interval) + intervals.push(handle) + return () => clearInterval(handle) } afterEach(() => { @@ -187,16 +189,26 @@ describe('State', () => { await state.doString(` test = "" - setInterval(function() + done = false + local stop + stop = setInterval(function() test = test .. "i" + if #test >= 5 then + stop() + done = true + end end, 1) `) - await setTimeout(20) - const test = state.global.get('test') - expect(test).length.above(3) - expect(test).length.below(21) - expect(test).to.be.equal(''.padEnd(test.length, 'i')) + const deadline = Date.now() + 1000 + while (!state.global.get('done')) { + if (Date.now() > deadline) { + throw new Error('timed out waiting for scheduled lua calls') + } + await setTimeout(5) + } + + expect(state.global.get('test')).to.be.equal('iiiii') }) it('scheduled lua calls should fail silently if invalid', async () => { From 2fc3a80ef7be480ceaabfbabb6d4d686956a6345 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Tue, 28 Jul 2026 17:32:28 -0300 Subject: [PATCH 39/52] bigint support and fixes --- bin/wasmoon | 6 +- src/engine.ts | 30 ++-- src/module.ts | 141 +++++++++++++++++-- src/thread.ts | 55 ++++++-- src/type-extensions/function.ts | 4 +- src/type-extensions/null.ts | 13 +- src/type-extensions/table.ts | 13 +- src/utils.ts | 21 +++ test/browser.test.js | 11 ++ test/engine.test.js | 237 ++++++++++++++++++++++++++++++++ test/node.test.js | 30 +++- utils/build-wasm.sh | 3 + 12 files changed, 516 insertions(+), 48 deletions(-) diff --git a/bin/wasmoon b/bin/wasmoon index db9cbb7..cc0138d 100755 --- a/bin/wasmoon +++ b/bin/wasmoon @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { Lua, LuaReturn, LuaType, LUA_MULTRET, decorate } from '../dist/index.js' +import { Lua, LuaReturn, LuaType, LUA_MULTRET, decorateProxy } from '../dist/index.js' import pkg from '../package.json' with { type: 'json' } import fs from 'node:fs' import readline from 'node:readline' @@ -193,7 +193,9 @@ for (const snippet of executeSnippets) { await state.doString(snippet) } -state.global.set('arg', decorate(scriptArgs, { disableProxy: true })) +// A real Lua table rather than a JS proxy, so `#arg` and `ipairs(arg)` behave like the +// standalone interpreter. +state.global.set('arg', decorateProxy(scriptArgs, { proxy: false })) const isTTY = process.stdin.isTTY diff --git a/src/engine.ts b/src/engine.ts index 1ad3109..8381ab8 100755 --- a/src/engine.ts +++ b/src/engine.ts @@ -80,9 +80,7 @@ export default class LuaEngine { * @returns - The result returned by the Lua script. */ public doStringSync(script: string): any { - this.global.loadString(script) - const result = this.global.runSync() - return result[0] + return this.callByteCodeSync((thread) => thread.loadString(script)) } /** @@ -91,9 +89,20 @@ export default class LuaEngine { * @returns - The result returned by the Lua script. */ public doFileSync(filename: string): any { - this.global.loadFile(filename) - const result = this.global.runSync() - return result[0] + return this.callByteCodeSync((thread) => thread.loadFile(filename)) + } + + private callByteCodeSync(loader: (thread: Thread) => void): any { + // Runs on the global thread, so leftovers would accumulate for the lifetime of the state. + // runSync has already converted the results to JS, and anything that has to outlive them + // holds its own registry reference. + const startStackTop = this.global.getTop() + try { + loader(this.global) + return this.global.runSync()[0] + } finally { + this.global.setTop(startStackTop) + } } // WARNING: It will not wait for open handles and can potentially cause bugs if JS code tries to reference Lua after executed @@ -103,14 +112,7 @@ export default class LuaEngine { const ref = this.module.luaL_ref(this.global.address, LUA_REGISTRYINDEX) try { loader(thread) - const result = await thread.run(0) - if (result.length > 0) { - // The shenanigans here are to return the first result value on the stack. - // Say there's 2 values at stack indexes 1 and 2. Then top is 2, result.length is 2. - // That's why there's a + 1 sitting at the end. - return thread.getValue(thread.getTop() - result.length + 1) - } - return undefined + return (await thread.run(0))[0] } finally { this.module.luaL_unref(this.global.address, LUA_REGISTRYINDEX, ref) } diff --git a/src/module.ts b/src/module.ts index 4a01496..f9f92a0 100755 --- a/src/module.ts +++ b/src/module.ts @@ -1,5 +1,5 @@ import initWasmModule from '../build/glue.js' -import { LUA_REGISTRYINDEX, LuaReturn, LuaState, LuaType } from './types.js' +import { LUA_REGISTRYINDEX, LuaReturn, LuaState, LuaType, PointerSize } from './types.js' // A rolldown plugin will resolve this to the current version on package.json import version from 'package-version' @@ -30,6 +30,12 @@ interface LuaEmscriptenModule extends EmscriptenModule { _realloc: (pointer: number, size: number) => number } +// Above this a dedicated allocation is used, so one huge string cannot permanently retain the +// scratch buffer. +const REUSABLE_STRING_BUFFER_LIMIT = 64 * 1024 +// Worst case UTF-8 expansion for a JS (UTF-16) string. +const MAX_UTF8_BYTES_PER_CHAR = 3 + interface ReferenceMetadata { index: number refCount: number @@ -158,7 +164,6 @@ export default class LuaModule { public luaL_checkversion_: (L: LuaState, ver: number, sz: number) => void public luaL_getmetafield: (L: LuaState, obj: number, e: string | null) => LuaType public luaL_callmeta: (L: LuaState, obj: number, e: string | null) => number - public luaL_tolstring: (L: LuaState, idx: number, len: number | null) => string public luaL_argerror: (L: LuaState, arg: number, extramsg: string | null) => number public luaL_typeerror: (L: LuaState, arg: number, tname: string | null) => number public luaL_checklstring: (L: LuaState, arg: number, l: number | null) => string @@ -230,7 +235,6 @@ export default class LuaModule { public lua_tonumberx: (L: LuaState, idx: number, isnum: number | null) => number public lua_tointegerx: (L: LuaState, idx: number, isnum: number | null) => bigint public lua_toboolean: (L: LuaState, idx: number) => number - public lua_tolstring: (L: LuaState, idx: number, len: number | null) => string public lua_rawlen: (L: LuaState, idx: number) => bigint public lua_tocfunction: (L: LuaState, idx: number) => number public lua_touserdata: (L: LuaState, idx: number) => number @@ -242,8 +246,7 @@ export default class LuaModule { public lua_pushnil: (L: LuaState) => void public lua_pushnumber: (L: LuaState, n: number) => void public lua_pushinteger: (L: LuaState, n: bigint) => void - public lua_pushlstring: (L: LuaState, s: string | number | null, len: number) => string - public lua_pushstring: (L: LuaState, s: string | number | null) => string + public lua_pushlstring: (L: LuaState, s: number, len: number) => void public lua_pushcclosure: (L: LuaState, fn: number, n: number) => void public lua_pushboolean: (L: LuaState, b: number) => void public lua_pushlightuserdata: (L: LuaState, p: number | null) => void @@ -317,13 +320,25 @@ export default class LuaModule { private availableReferences: number[] = [] private lastRefIndex?: number + // Lua strings are byte arrays that may contain NUL, so they cannot go through Emscripten's + // NUL-terminated string marshalling. These work on pointers and explicit lengths instead. + private readonly rawLuaToLString: (L: LuaState, idx: number, len: number) => number + private readonly rawLuaLToLString: (L: LuaState, idx: number, len: number) => number + private readonly rawLuaPushString: (L: LuaState, s: number) => number + + private readonly textDecoder = new TextDecoder() + private readonly textEncoder = new TextEncoder() + // C writes it immediately before returning and we read it straight after with no interleaving + // await, so a single shared slot stays reentrancy safe. + private readonly sizeScratch: number + private stringBuffer = 0 + public constructor(module: LuaEmscriptenModule) { this._emscripten = module this.luaL_checkversion_ = this.cwrap('luaL_checkversion_', null, ['number', 'number', 'number']) this.luaL_getmetafield = this.cwrap('luaL_getmetafield', 'number', ['number', 'number', 'string']) this.luaL_callmeta = this.cwrap('luaL_callmeta', 'number', ['number', 'number', 'string']) - this.luaL_tolstring = this.cwrap('luaL_tolstring', 'string', ['number', 'number', 'number']) this.luaL_argerror = this.cwrap('luaL_argerror', 'number', ['number', 'number', 'string']) this.luaL_typeerror = this.cwrap('luaL_typeerror', 'number', ['number', 'number', 'string']) this.luaL_checklstring = this.cwrap('luaL_checklstring', 'string', ['number', 'number', 'number']) @@ -389,7 +404,6 @@ export default class LuaModule { this.lua_tonumberx = this.cwrap('lua_tonumberx', 'number', ['number', 'number', 'number']) this.lua_tointegerx = this.cwrap('lua_tointegerx', 'number', ['number', 'number', 'number']) this.lua_toboolean = this.cwrap('lua_toboolean', 'number', ['number', 'number']) - this.lua_tolstring = this.cwrap('lua_tolstring', 'string', ['number', 'number', 'number']) this.lua_rawlen = this.cwrap('lua_rawlen', 'number', ['number', 'number']) this.lua_tocfunction = this.cwrap('lua_tocfunction', 'number', ['number', 'number']) this.lua_touserdata = this.cwrap('lua_touserdata', 'number', ['number', 'number']) @@ -401,8 +415,6 @@ export default class LuaModule { this.lua_pushnil = this.cwrap('lua_pushnil', null, ['number']) this.lua_pushnumber = this.cwrap('lua_pushnumber', null, ['number', 'number']) this.lua_pushinteger = this.cwrap('lua_pushinteger', null, ['number', 'number']) - this.lua_pushlstring = this.cwrap('lua_pushlstring', 'string', ['number', 'string|number', 'number']) - this.lua_pushstring = this.cwrap('lua_pushstring', 'string', ['number', 'string|number']) this.lua_pushcclosure = this.cwrap('lua_pushcclosure', null, ['number', 'number', 'number']) this.lua_pushboolean = this.cwrap('lua_pushboolean', null, ['number', 'number']) this.lua_pushlightuserdata = this.cwrap('lua_pushlightuserdata', null, ['number', 'number']) @@ -471,6 +483,82 @@ export default class LuaModule { this.luaopen_debug = this.cwrap('luaopen_debug', 'number', ['number']) this.luaopen_package = this.cwrap('luaopen_package', 'number', ['number']) this.luaL_openlibs = (L) => this.luaL_openselectedlibs(L, -1, 0) + + this.rawLuaToLString = this.cwrap('lua_tolstring', 'number', ['number', 'number', 'number']) + this.rawLuaLToLString = this.cwrap('luaL_tolstring', 'number', ['number', 'number', 'number']) + this.rawLuaPushString = this.cwrap('lua_pushstring', 'number', ['number', 'number']) + this.lua_pushlstring = this.cwrap('lua_pushlstring', 'number', ['number', 'number', 'number']) + + this.sizeScratch = module._malloc(PointerSize) + if (!this.sizeScratch) { + throw new Error('failed to allocate the scratch buffer for string lengths') + } + } + + /** + * Bytes that aren't valid UTF-8 are replaced with U+FFFD. Use {@link lua_tobytes} when the + * value holds arbitrary binary data (`string.dump`, `string.pack`, ciphertext, ...). + */ + public lua_tolstring(L: LuaState, idx: number, len: number | null = null): string { + return this.toLString(this.rawLuaToLString, L, idx, len) + } + + /** Goes through the `__tostring` metamethod, which leaves the result on the stack. */ + public luaL_tolstring(L: LuaState, idx: number, len: number | null = null): string { + return this.toLString(this.rawLuaLToLString, L, idx, len) + } + + public lua_tobytes(L: LuaState, idx: number): Uint8Array | undefined { + const pointer = this.rawLuaToLString(L, idx, this.sizeScratch) + if (!pointer) { + return undefined + } + // Copied, because the caller may outlive the next heap growth. + return this.heap.slice(pointer, pointer + this.readSize(this.sizeScratch)) + } + + /** A number is a pointer to a NUL-terminated C string, and `null` pushes nil, as in C. */ + public lua_pushstring(L: LuaState, s: string | number | null): void { + if (s === null || s === undefined) { + this.lua_pushnil(L) + } else if (typeof s === 'number') { + this.rawLuaPushString(L, s) + } else if (s.length === 0) { + this.lua_pushlstring(L, 0, 0) + } else { + const capacity = s.length * MAX_UTF8_BYTES_PER_CHAR + const pointer = this.acquireStringBuffer(capacity) + try { + // encodeInto avoids materialising an intermediate Uint8Array for the whole string. + const { written } = this.textEncoder.encodeInto(s, this.heap.subarray(pointer, pointer + capacity)) + this.lua_pushlstring(L, pointer, written) + } finally { + this.releaseStringBuffer(pointer) + } + } + } + + public lua_pushbytes(L: LuaState, bytes: Uint8Array): void { + const pointer = this.acquireStringBuffer(bytes.length) + try { + this.heap.set(bytes, pointer) + this.lua_pushlstring(L, pointer, bytes.length) + } finally { + this.releaseStringBuffer(pointer) + } + } + + public readString(pointer: number, length: number): string { + if (!length) { + return '' + } + return this.textDecoder.decode(this.heap.subarray(pointer, pointer + length)) + } + + private toLString(raw: (L: LuaState, idx: number, len: number) => number, L: LuaState, idx: number, len: number | null): string { + const lengthPointer = len ?? this.sizeScratch + const pointer = raw(L, idx, lengthPointer) + return pointer ? this.readString(pointer, this.readSize(lengthPointer)) : '' } public lua_remove(luaState: LuaState, index: number): void { @@ -550,6 +638,41 @@ export default class LuaModule { } } + // Never cache this: ALLOW_MEMORY_GROWTH swaps the underlying buffer when the heap grows, and + // Emscripten reassigns HEAPU8 to match. + private get heap(): Uint8Array { + return this._emscripten.HEAPU8 + } + + private readSize(pointer: number): number { + return this._emscripten.HEAPU32[pointer >>> 2] + } + + private acquireStringBuffer(size: number): number { + if (size > REUSABLE_STRING_BUFFER_LIMIT) { + const pointer = this._emscripten._malloc(size) + if (!pointer) { + throw new Error(`failed to allocate ${size} bytes for a string`) + } + return pointer + } + + if (!this.stringBuffer) { + this.stringBuffer = this._emscripten._malloc(REUSABLE_STRING_BUFFER_LIMIT) + if (!this.stringBuffer) { + throw new Error(`failed to allocate ${REUSABLE_STRING_BUFFER_LIMIT} bytes for the string buffer`) + } + } + + return this.stringBuffer + } + + private releaseStringBuffer(pointer: number): void { + if (pointer !== this.stringBuffer) { + this._emscripten._free(pointer) + } + } + private cwrap( name: string, returnType: Emscripten.JSType | null, diff --git a/src/thread.ts b/src/thread.ts index 98110c0..274f570 100755 --- a/src/thread.ts +++ b/src/thread.ts @@ -14,7 +14,7 @@ import { LuaType, PointerSize, } from './types' -import { isPromise } from './utils' +import { isEmscriptenUnwind, isPromise, yieldToEventLoop } from './utils' export interface OrderedExtension { // Bigger is more important @@ -25,6 +25,8 @@ export interface OrderedExtension { // When the debug count hook is set, call it every X instructions. const INSTRUCTION_HOOK_COUNT = 1000 +const LUA_INTEGER_BITS = 64 + export default class Thread { public readonly address: LuaState public readonly lua: LuaModule @@ -126,11 +128,11 @@ export default class Thread { await lastValue } else { // If it's a non-promise, then skip a tick to yield for promises, timers, etc. - await new Promise((resolve) => setImmediate(resolve)) + await yieldToEventLoop() } } else { // If there's nothing to yield, then skip a tick to yield for promises, timers, etc. - await new Promise((resolve) => setImmediate(resolve)) + await yieldToEventLoop() } resumeResult = this.resume(0) @@ -205,12 +207,20 @@ export default class Thread { this.lua.lua_pushnil(this.address) break case 'number': - if (Number.isInteger(target)) { + // Only integers JS can represent exactly become Lua integers. Values like 1e300 + // are integral but far outside int64, and would wrap silently if pushed as one. + if (Number.isSafeInteger(target)) { this.lua.lua_pushinteger(this.address, BigInt(target)) } else { this.lua.lua_pushnumber(this.address, target) } break + case 'bigint': + if (BigInt.asIntN(LUA_INTEGER_BITS, target) !== target) { + throw new RangeError(`bigint ${target} does not fit in a 64 bit Lua integer`) + } + this.lua.lua_pushinteger(this.address, target) + break case 'string': this.lua.lua_pushstring(this.address, target) break @@ -279,8 +289,15 @@ export default class Thread { return undefined case LuaType.Nil: return null - case LuaType.Number: - return this.lua.lua_tonumberx(this.address, index, null) + case LuaType.Number: { + const value = this.lua.lua_tonumberx(this.address, index, null) + // Only outside the safe range can the integer subtype change the result, and + // checking that first keeps the common case to a single wasm call. + if (Number.isSafeInteger(value) || !this.lua.lua_isinteger(this.address, index)) { + return value + } + return this.lua.lua_tointegerx(this.address, index, null) + } case LuaType.String: return this.lua.lua_tolstring(this.address, index, null) case LuaType.Boolean: @@ -324,17 +341,18 @@ export default class Thread { if (timeout && timeout > 0) { if (!this.hookFunctionPointer) { this.hookFunctionPointer = this.lua._emscripten.addFunction((): void => { - if (Date.now() > timeout) { + // Reads this.timeout rather than closing over the argument, so a hook + // allocated for an earlier deadline still honours the current one. + if (this.timeout !== undefined && Date.now() > this.timeout) { this.pushValue(new LuaTimeoutError(`thread timeout exceeded`)) this.lua.lua_error(this.address) } }, 'vii') } - this.lua.lua_sethook(this.address, this.hookFunctionPointer!, LuaEventMasks.Count, INSTRUCTION_HOOK_COUNT) this.timeout = timeout - } else if (this.hookFunctionPointer) { - this.hookFunctionPointer = undefined + this.lua.lua_sethook(this.address, this.hookFunctionPointer, LuaEventMasks.Count, INSTRUCTION_HOOK_COUNT) + } else { this.timeout = undefined this.lua.lua_sethook(this.address, null, 0, 0) } @@ -359,6 +377,20 @@ export default class Thread { return str } + /** + * Values holding binary data (`string.dump`, `string.pack`, ciphertext, ...) cannot survive a + * round trip through a JS string, so use this and {@link pushStringBytes} for those instead + * of getValue/pushValue. + * @returns the bytes, or undefined if the value is neither a string nor a number. + */ + public getStringBytes(index: number): Uint8Array | undefined { + return this.lua.lua_tobytes(this.address, index) + } + + public pushStringBytes(bytes: Uint8Array): void { + this.lua.lua_pushbytes(this.address, bytes) + } + public dumpStack(log = console.log): void { const top = this.getTop() @@ -403,6 +435,9 @@ export default class Thread { } this.pop(1) // pop stack trace. } catch (err) { + if (isEmscriptenUnwind(err)) { + throw err + } console.warn('Failed to generate stack trace', err) } } diff --git a/src/type-extensions/function.ts b/src/type-extensions/function.ts index cf46286..db39794 100644 --- a/src/type-extensions/function.ts +++ b/src/type-extensions/function.ts @@ -5,6 +5,7 @@ import RawResult from '../raw-result' import Thread from '../thread' import TypeExtension from '../type-extension' import { LUA_REGISTRYINDEX, LuaReturn, LuaState, LuaType, PointerSize } from '../types' +import { isEmscriptenUnwind } from '../utils' export interface FunctionDecoration extends BaseDecorationOptions { receiveArgsQuantity?: boolean @@ -114,8 +115,7 @@ class FunctionTypeExtension extends TypeExtension { private gcPointer: number + private nullReference: number public constructor(thread: Global) { super(thread, 'js_null') @@ -46,7 +47,12 @@ class NullTypeExtension extends TypeExtension { // Create a new table, this is unique and will be the "null" value by attaching the // metatable created above. The first argument is the target, the second options. super.pushValue(thread, new Decoration({}, {})) - // Put it into the global field named null. + + // Lua code is free to reassign the `null` global, so marshalling anchors the sentinel in + // the registry instead of looking it up by name. + thread.lua.lua_pushvalue(thread.address, -1) + this.nullReference = thread.lua.luaL_ref(thread.address, LUA_REGISTRYINDEX) + thread.lua.lua_setglobal(thread.address, 'null') } @@ -63,8 +69,7 @@ class NullTypeExtension extends TypeExtension { if (decoration?.target !== null) { return false } - // Rather than pushing a new value, get the global "null" onto the stack. - thread.lua.lua_getglobal(thread.address, 'null') + thread.lua.lua_rawgeti(thread.address, LUA_REGISTRYINDEX, BigInt(this.nullReference)) return true } diff --git a/src/type-extensions/table.ts b/src/type-extensions/table.ts index 6fb4335..3ca449b 100644 --- a/src/type-extensions/table.ts +++ b/src/type-extensions/table.ts @@ -65,19 +65,20 @@ class TableTypeExtension extends TypeExtension { createTable(target.length, 0) for (let i = 0; i < target.length; i++) { - thread.pushValue(i + 1, seenMap) thread.pushValue(target[i], seenMap) - - thread.lua.lua_settable(thread.address, tableIndex) + // Raw, so the table being built cannot be observed through metamethods. + thread.lua.lua_rawseti(thread.address, tableIndex, BigInt(i + 1)) } } else { - createTable(0, Object.getOwnPropertyNames(target).length) + // A for..in loop would also walk the prototype chain and copy inherited members. + const keys = Object.keys(target) + createTable(0, keys.length) - for (const key in target) { + for (const key of keys) { thread.pushValue(key, seenMap) thread.pushValue((target as Record)[key], seenMap) - thread.lua.lua_settable(thread.address, tableIndex) + thread.lua.lua_rawset(thread.address, tableIndex) } } } finally { diff --git a/src/utils.ts b/src/utils.ts index 68b54c0..1e6744e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,24 @@ export const isPromise = (target: any): target is Promise => { return target && (Promise.resolve(target) === target || typeof target.then === 'function') } + +/** + * Emscripten unwinds a Lua longjmp by throwing from its internal EmscriptenEH hierarchy. The + * wasm caller gates on `instanceof EmscriptenEH` to resume unwinding, so these have to be + * rethrown rather than raised as Lua errors. The class is module local in the generated glue, + * which leaves the name as the only usable signal. + */ +export const isEmscriptenUnwind = (value: unknown): boolean => { + return typeof value === 'object' && value !== null && String(value.constructor?.name).startsWith('Emscripten') +} + +// Browsers have no setImmediate. The 4ms clamp on nested timers is acceptable here. +const scheduleMacrotask = typeof setImmediate === 'function' ? setImmediate : (task: () => void) => setTimeout(task, 0) + +/** + * A macrotask, so pending promise callbacks *and* timers get a chance to run before Lua is + * resumed. A microtask would starve timer driven code such as setTimeout based sleeps. + */ +export const yieldToEventLoop = (): Promise => { + return new Promise((resolve) => scheduleMacrotask(() => resolve())) +} diff --git a/test/browser.test.js b/test/browser.test.js index 2dd0afc..57d94e2 100644 --- a/test/browser.test.js +++ b/test/browser.test.js @@ -191,4 +191,15 @@ describe('Browser environment', () => { `) expect(result).to.be.equal(6) }) + + it('yielding at the top level in browser should succeed', async function () { + this.timeout(30_000) + // Browsers have no setImmediate, which the yield path used to depend on. + const result = await runInBrowser(` + const lua = await Lua.load({ wasmFile }) + const state = lua.createState() + return await state.doString('coroutine.yield() return 7') + `) + expect(result).to.be.equal(7) + }) }) diff --git a/test/engine.test.js b/test/engine.test.js index 639a8fd..c5846de 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -168,6 +168,41 @@ describe('State', () => { expect(value).to.be.equal('world') }) + it('inherited properties should not be copied into the lua table', async () => { + const state = await getState({ enableProxy: false }) + const obj = Object.create({ inherited: 'yes' }) + obj.own = 'mine' + state.global.set('t', obj) + + const keys = await state.doString(` + local out = {} + for key in pairs(t) do out[#out + 1] = key end + table.sort(out) + return table.concat(out, ',') + `) + + expect(keys).to.be.equal('own') + }) + + it('non enumerable properties should not be copied into the lua table', async () => { + const state = await getState({ enableProxy: false }) + const obj = { own: 'mine' } + Object.defineProperty(obj, 'hidden', { value: 1, enumerable: false }) + state.global.set('t', obj) + + expect(await state.doString('return t.hidden == nil')).to.be.true + expect(await state.doString('return t.own')).to.be.equal('mine') + }) + + it('nested arrays and objects should be copied into the lua table', async () => { + const state = await getState({ enableProxy: false }) + state.global.set('t', { list: [10, 20, 30], nested: { deep: [{ value: 5 }] } }) + + expect(await state.doString('return #t.list')).to.be.equal(3) + expect(await state.doString('return t.list[1]')).to.be.equal(10) + expect(await state.doString('return t.nested.deep[1].value')).to.be.equal(5) + }) + it('a lua error should throw on JS', async () => { const state = await getState() @@ -527,6 +562,31 @@ describe('State', () => { await expect(state.global.run(0, { timeout: 5 })).eventually.to.be.rejectedWith('thread timeout exceeded') }) + it('the most recently set timeout should be the one that applies', async function () { + this.timeout(30_000) + const state = await getState() + const thread = state.global.newThread() + + thread.setTimeout(Date.now() + 20) + thread.setTimeout(Date.now() + 20_000) + thread.loadString('local x = 0 for i = 1, 20000000 do x = x + 1 end return x') + + expect((await thread.run(0))[0]).to.be.equal(20000000) + }) + + it('clearing a timeout should disable the hook', async function () { + this.timeout(30_000) + const state = await getState() + const thread = state.global.newThread() + + thread.setTimeout(Date.now() + 10) + thread.setTimeout(undefined) + thread.loadString('local x = 0 for i = 1, 5000000 do x = x + 1 end return 7') + + expect(thread.getTimeout()).to.be.undefined + expect((await thread.run(0))[0]).to.be.equal(7) + }) + it('overwrite lib function', async () => { const state = await getState() @@ -681,6 +741,82 @@ describe('State', () => { expect(res).to.be.equal(str) }) + it('a large multibyte string should keep its byte length', async function () { + this.timeout(30_000) + const state = await getState() + const str = 'á'.repeat(500000) + + state.global.set('str', str) + + expect(await state.doString('return #str')).to.be.equal(1000000) + expect(await state.doString('return str')).to.be.equal(str) + }) + + it('a string containing NUL should be pushed with its full length', async () => { + const state = await getState() + state.global.set('str', 'a\0b') + + expect(await state.doString('return #str')).to.be.equal(3) + }) + + it('a string containing NUL should be retrieved with its full length', async () => { + const state = await getState() + + const res = await state.doString('return "x\\0y"') + + expect(res).to.be.equal('x\0y') + }) + + it('a string containing NUL should round trip unchanged', async () => { + const state = await getState() + const str = 'before\0middle\0after' + state.global.set('str', str) + + expect(await state.doString('return str')).to.be.equal(str) + }) + + it('an empty string should round trip unchanged', async () => { + const state = await getState() + state.global.set('str', '') + + expect(await state.doString('return #str')).to.be.equal(0) + expect(await state.doString('return str')).to.be.equal('') + }) + + it('getStringBytes should expose bytes that are not valid UTF-8', async () => { + const state = await getState() + await state.doString('value = string.char(0, 255, 128, 65)') + + state.global.lua.lua_getglobal(state.global.address, 'value') + const bytes = state.global.getStringBytes(-1) + state.global.pop() + + expect(Array.from(bytes)).to.be.eql([0, 255, 128, 65]) + }) + + it('getStringBytes should return undefined for a non string value', async () => { + const state = await getState() + state.global.pushValue({}) + + expect(state.global.getStringBytes(-1)).to.be.undefined + + state.global.pop() + }) + + it('bytecode should round trip through the byte accessors', async () => { + const state = await getState() + await state.doString('bytecode = string.dump(load("return 42"))') + + state.global.lua.lua_getglobal(state.global.address, 'bytecode') + const bytes = state.global.getStringBytes(-1) + state.global.pop() + + state.global.pushStringBytes(bytes) + state.global.lua.lua_setglobal(state.global.address, 'roundtripped') + + expect(await state.doString('return load(roundtripped)()')).to.be.equal(42) + }) + it('negative integers should be pushed and retrieved as string', async () => { const state = await getState() state.global.set('value', -1) @@ -748,6 +884,59 @@ describe('State', () => { expect(roundTrip).to.be.equal(value) }) + it('integers outside the JS safe range should be retrieved as bigint', async () => { + const state = await getState() + + expect(await state.doString('return math.maxinteger')).to.be.equal(9223372036854775807n) + expect(await state.doString('return math.mininteger')).to.be.equal(-9223372036854775808n) + }) + + it('integers inside the JS safe range should be retrieved as number', async () => { + const state = await getState() + + const res = await state.doString('return 1689031554550') + + expect(res).to.be.equal(1689031554550) + expect(res).to.be.a('number') + }) + + it('an integral number should be pushed as a lua integer', async () => { + const state = await getState() + state.global.set('value', 2) + + expect(await state.doString('return math.type(value)')).to.be.equal('integer') + }) + + it('floats should be retrieved as number', async () => { + const state = await getState() + + expect(await state.doString('return 1.5')).to.be.equal(1.5) + expect(await state.doString('return math.type(1.5)')).to.be.equal('float') + }) + + it('a bigint should be pushed as a 64 bit lua integer', async () => { + const state = await getState() + state.global.set('value', 9223372036854775807n) + + expect(await state.doString('return tostring(value)')).to.be.equal('9223372036854775807') + expect(await state.doString('return math.type(value)')).to.be.equal('integer') + expect(await state.doString('return value')).to.be.equal(9223372036854775807n) + }) + + it('a bigint outside the lua integer range should throw', async () => { + const state = await getState() + + expect(() => state.global.set('value', 2n ** 70n)).to.throw(RangeError) + }) + + it('an integral number too large for a lua integer should be pushed as a float', async () => { + const state = await getState() + state.global.set('value', 1e300) + + expect(await state.doString('return math.type(value)')).to.be.equal('float') + expect(await state.doString('return value')).to.be.equal(1e300) + }) + it('yielding in a JS callback into Lua does not break lua state', async () => { // When yielding within a callback the error 'attempt to yield across a C-call boundary'. // This test just checks that throwing that error still allows the lua global to be @@ -822,6 +1011,23 @@ describe('State', () => { expect(res).to.deep.equal([null, null, 'nil']) }) + it('reassigning the null global should not affect pushing null', async () => { + const state = await getState() + await state.doString('null = 5') + + state.global.set('value', null) + + expect(await state.doString('return type(value)')).to.be.equal('userdata') + expect(state.global.get('value')).to.be.null + }) + + it('a pushed null should equal the injected null global', async () => { + const state = await getState() + state.global.set('value', null) + + expect(await state.doString('return value == null')).to.be.true + }) + it('Nested callback from JS to Lua', async () => { const state = await getState() state.global.set('call', (fn) => fn()) @@ -847,6 +1053,37 @@ describe('State', () => { } }) + it('lots of doStringSync calls should not grow the lua stack', async function () { + this.timeout(30_000) + const state = await getState() + const startTop = state.global.getTop() + + for (let i = 0; i < 500; i++) { + expect(state.doStringSync(`return ${i}`)).to.be.equal(i) + } + + expect(state.global.getTop()).to.be.equal(startTop) + }) + + it('a failing doStringSync should not grow the lua stack', async () => { + const state = await getState() + const startTop = state.global.getTop() + + expect(() => state.doStringSync('error("boom")')).to.throw('boom') + + expect(state.global.getTop()).to.be.equal(startTop) + }) + + it('values returned by doStringSync should outlive the stack reset', async () => { + const state = await getState() + + const fn = state.doStringSync('return function() return 3 end') + const table = state.doStringSync('return { a = 1, b = { 2, 3 } }') + + expect(fn()).to.be.equal(3) + expect(table).to.be.eql({ a: 1, b: [2, 3] }) + }) + it('many concurrent doString calls should succeed', async function () { this.timeout(15000) const state = await getState() diff --git a/test/node.test.js b/test/node.test.js index 51d8dcb..1fd0ded 100644 --- a/test/node.test.js +++ b/test/node.test.js @@ -1,9 +1,24 @@ -import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs' +import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' +import { spawn } from 'node:child_process' import { expect } from 'chai' import { Lua } from '../dist/index.js' +// stdin stays closed, as it would be for a redirected or piped invocation. The CLI keeps a +// readline interface open otherwise and never exits. +const runCli = (args) => { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['bin/wasmoon', ...args], { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => (stdout += chunk)) + child.stderr.on('data', (chunk) => (stderr += chunk)) + child.on('error', reject) + child.on('close', (code) => resolve({ code, stdout, stderr })) + }) +} + describe('Node environment', () => { it('load Lua engine in node should succeed', async () => { const lua = await Lua.load() @@ -247,4 +262,17 @@ describe('Node environment', () => { `) expect(result).to.be.true }) + + it('cli should expose arg as a lua table', async function () { + this.timeout(30_000) + const directory = mkdtempSync(join(tmpdir(), 'wasmoon-cli-')) + const script = join(directory, 'args.lua') + writeFileSync(script, 'print(type(arg), #arg, arg[1], arg[2], table.concat(arg, "+"))') + + const { code, stdout, stderr } = await runCli([script, 'first', 'second']) + + expect(stderr).to.be.empty + expect(code).to.be.equal(0) + expect(stdout.trim()).to.be.equal('table\t2\tfirst\tsecond\tfirst+second') + }) }) diff --git a/utils/build-wasm.sh b/utils/build-wasm.sh index f64546f..046430c 100755 --- a/utils/build-wasm.sh +++ b/utils/build-wasm.sh @@ -4,6 +4,8 @@ mkdir -p ../build LUA_SRC=$(ls ../lua/*.c | grep -v "luac.c" | grep -v "lua.c" | tr "\n" " ") +# Do not add --closure here: isEmscriptenUnwind identifies in-flight longjmps by class name, so +# mangling them would silently turn every unwind into a Lua error. extension="" if [ "$1" == "dev" ]; then @@ -29,6 +31,7 @@ emcc \ 'stringToNewUTF8', \ 'intArrayFromString', \ 'UTF8ToString', \ + 'HEAPU8', \ 'HEAPU32' ]" \ -s DEFAULT_LIBRARY_FUNCS_TO_INCLUDE="[ From ab84d4db900c52eaf862b9ce6e9de3d87a0c4398 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Tue, 28 Jul 2026 17:49:09 -0300 Subject: [PATCH 40/52] chore: update packages --- .github/workflows/publish.yml | 18 +- .github/workflows/test.yml | 10 +- .oxlintrc.json | 5 + package-lock.json | 1009 +++++++++++++++++++++++---------- package.json | 16 +- 5 files changed, 747 insertions(+), 311 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 510a043..a9132c6 100755 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,17 +9,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: recursive - name: Setup EMSDK - uses: mymindstorm/setup-emsdk@v14 + uses: emscripten-core/setup-emsdk@v16 with: - version: 5.0.4 + version: 6.0.4 + actions-cache-folder: emsdk-cache - name: Use Node.js 24.x - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24.x @@ -35,6 +36,9 @@ jobs: - name: Build project run: npm run build + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + - name: Run tests run: npm test @@ -57,7 +61,7 @@ jobs: needs: build steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Download build artifact uses: actions/download-artifact@v8 @@ -69,7 +73,7 @@ jobs: run: cp -r build-artifact/* . - name: Publish to npm - uses: JS-DevTools/npm-publish@v3 + uses: JS-DevTools/npm-publish@v4 with: token: ${{ secrets.NPM_TOKEN }} @@ -82,7 +86,7 @@ jobs: # id-token: write # steps: # - name: Download build artifact - # uses: actions/download-artifact@v4 + # uses: actions/download-artifact@v8 # with: # name: build-artifact # path: build-artifact diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 06a732e..a588f0b 100755 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,16 +13,18 @@ jobs: node-version: [22, 24] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: submodules: recursive - - uses: mymindstorm/setup-emsdk@v14 + - uses: emscripten-core/setup-emsdk@v16 with: - version: 5.0.4 + version: 6.0.4 + actions-cache-folder: emsdk-cache - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} + cache: npm - run: npm ci - run: npm run lint:nofix - run: npm run build:wasm diff --git a/.oxlintrc.json b/.oxlintrc.json index 1769aea..676a206 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,8 +1,13 @@ { "plugins": ["import", "promise", "node"], + "categories": { + "correctness": "deny", + "suspicious": "deny" + }, "rules": { "no-shadow": "off", "no-extend-native": "off", + "no-underscore-dangle": "off", "consistent-function-scoping": "off", "promise/always-return": "off" }, diff --git a/package-lock.json b/package-lock.json index 6c89b92..0e14f85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,19 +19,19 @@ "chai": "6.2.2", "chai-as-promised": "8.0.2", "fengari": "0.1.5", - "mocha": "11.7.5", - "oxfmt": "0.46.0", - "oxlint": "1.61.0", - "playwright": "1.59.1", - "rolldown": "1.0.0-rc.17", + "mocha": "11.7.6", + "oxfmt": "0.61.0", + "oxlint": "1.76.0", + "playwright": "1.62.0", + "rolldown": "1.2.0", "tslib": "2.8.1", - "typescript": "6.0.3" + "typescript": "7.0.2" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", "dev": true, "license": "MIT", "optional": true, @@ -58,28 +58,31 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz", + "integrity": "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^2.0.0-alpha.3", + "@emnapi/runtime": "^2.0.0-alpha.3" } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.140.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.140.0.tgz", + "integrity": "sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==", "dev": true, "license": "MIT", "funding": { @@ -87,9 +90,9 @@ } }, "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.46.0.tgz", - "integrity": "sha512-b1doV4WRcJU+BESSlCvCjV+5CEr/T6h0frArAdV26Nir+gGNFNaylvDiiMPfF1pxeV0txZEs38ojzJaxBYg+ng==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.61.0.tgz", + "integrity": "sha512-BaS+1OVvg9sr+Xav0+KdWedQRcAzrdoEcwMZeqoc2F6ieC1s/t5eM35YQoRPQ7vAqkZ+p3tbQb1r9I9mrV5oGA==", "cpu": [ "arm" ], @@ -104,9 +107,9 @@ } }, "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.46.0.tgz", - "integrity": "sha512-v6+HhjsoV3GO0u2u9jLSAZrvWfTraDxKofUIQ7/ktS7tzS+epVsxdHmeM+XxuNcAY/nWxxU1Sg4JcGTNRXraBA==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.61.0.tgz", + "integrity": "sha512-of8atAV0M1egGcVOMbgZCvc10sFOP3ayQBNQV5h5G3fNq8gACdEswfFk9bzGrdbM23rtg0Coxi7np7oPLcueNw==", "cpu": [ "arm64" ], @@ -121,9 +124,9 @@ } }, "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.46.0.tgz", - "integrity": "sha512-3eeooJGrqGIlI5MyryDZsAcKXSmKIgAD4yYtfRrRJzXZ0UTFZtiSveIur56YPrGMYZwT4XyVhHsMqrNwr1XeFA==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.61.0.tgz", + "integrity": "sha512-7l8+5ov4BGwtAcmpzvEik/TG3bciwyw/S3e6j5GKH7pcQqcgCVxD3AuJeP6upto+SOTBKQ4wrrdbMt0gq8fHSQ==", "cpu": [ "arm64" ], @@ -138,9 +141,9 @@ } }, "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.46.0.tgz", - "integrity": "sha512-QG8BDM0CXWbu84k2SKmCqfEddPQPFiBicwtYnLqHRWZZl57HbtOLRMac/KTq2NO4AEc4ICCBpFxJIV9zcqYfkQ==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.61.0.tgz", + "integrity": "sha512-Fnz4dDDXBb7udk+DmwelNjxbD6yptyxwCqwCH2ebo4RVLxVsRfFsn/AHJC49KIltPrVokamGv4SSOsiV50DTxQ==", "cpu": [ "x64" ], @@ -155,9 +158,9 @@ } }, "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.46.0.tgz", - "integrity": "sha512-9DdCqS/n2ncu/Chazvt3cpgAjAmIGQDz7hFKSrNItMApyV/Ja9mz3hD4JakIE3nS8PW9smEbPWnb389QLBY4nw==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.61.0.tgz", + "integrity": "sha512-mddOebKNCP+AucmzfNsk3jgbr681qAUvgMqi865GW5gWLJ/AnzXbvjQRrny0e++NAN8aphav/aRSrfFxNsNjpA==", "cpu": [ "x64" ], @@ -172,9 +175,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.46.0.tgz", - "integrity": "sha512-Dgs7VeE2jT0LHMhw6tPEt0xQYe54kBqHEovmWsv4FVQlegCOvlIJNx0S8n4vj8WUtpT+Z6BD2HhKJPLglLxvZg==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.61.0.tgz", + "integrity": "sha512-svx59iYL+DbaZGZUIoice4W0CjRXGExnbz7Re+awIb60rVxBS2KrU7Hnlx+nZYanLGLpjneUEgo/VFEKkSZAyQ==", "cpu": [ "arm" ], @@ -189,9 +192,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.46.0.tgz", - "integrity": "sha512-Zxn3adhTH13JKnU4xXJj8FeEfF680XjXh3gSShKl57HCMBRde2tUJTgogV/1MSHA80PJEVrDa7r66TLVq3Ia7Q==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.61.0.tgz", + "integrity": "sha512-BYK9MPJPCf6d+fLKMTruThmEyCtHzQ1zLcsrTlUVkmnoXIaHAbfpeLYQwX1tkjs7W11dyzoi6HFvKcdnvX1zNg==", "cpu": [ "arm" ], @@ -206,9 +209,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.46.0.tgz", - "integrity": "sha512-+TWipjrgVM8D7aIdDD0tlr3teLTTvQTn7QTE5BpT10H1Fj82gfdn9X6nn2sDgx/MepuSCfSnzFNJq2paLL0OiA==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.61.0.tgz", + "integrity": "sha512-QUaCNLq2/EC6G5ljOuFanl9Lgw6ZWp4co7rs4+KOMUzbGfA4Lq58FHRjjF9sVIG+93XSbo343MxFATrOU1qctA==", "cpu": [ "arm64" ], @@ -223,9 +226,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.46.0.tgz", - "integrity": "sha512-aAUPBWJ1lGwwnxZUEDLJ94+Iy6MuwJwPxUgO4sCA5mEEyDk7b+cDQ+JpX1VR150Zoyd+D49gsrUzpUK5h587Eg==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.61.0.tgz", + "integrity": "sha512-S6uvJ6MXnRXl+zTs0CARNDvkE+cymj0EVWEKKsyKnlLlqTyQJBjw5s4D2pSIOZc+S46cy4STefzcr/sm0VzVPA==", "cpu": [ "arm64" ], @@ -240,9 +243,9 @@ } }, "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.46.0.tgz", - "integrity": "sha512-ufBCJukyFX/UDrokP/r6BGDoTInnsDs7bxyzKAgMiZlt2Qu8GPJSJ6Zm6whIiJzKk0naxA8ilwmbO1LMw6Htxw==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.61.0.tgz", + "integrity": "sha512-6VDlRcytvZG6UlSIdAFKDLbppo9tvPxrWzle6vHldYFMeuDPQEfMKrkwezp7FaBq1wik9ra554ZZeRPsyIkFpg==", "cpu": [ "ppc64" ], @@ -257,9 +260,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.46.0.tgz", - "integrity": "sha512-eqtlC2YmPqjun76R1gVfGLuKWx7NuEnLEAudZ7n6ipSKbCZTqIKSs1b5Y8K/JHZsRpLkeSmAAjig5HOIg8fQzQ==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.61.0.tgz", + "integrity": "sha512-KkBTYbzExpbmn15XjKPLu2fRV2PVlq+KWt+brad5rwIa03vdYoaDRWiS7raHII/dCTR6Ro4UpYUCH4t6lif4WQ==", "cpu": [ "riscv64" ], @@ -274,9 +277,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.46.0.tgz", - "integrity": "sha512-yccVOO2nMXkQLGgy0He3EQEwKD7NF0zEk+/OWmroznkqXyJdN6bfK0LtNnr6/14Bh3FjpYq7bP33l/VloCnxpA==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.61.0.tgz", + "integrity": "sha512-69tzIq7sJLVB9dxYYtvMzcSSsnZHSO+U2U19O2RqDqgj6+Q4O7HjSXdaszbcgqzhsUwzSH7z5kWvk8nmf6BHTg==", "cpu": [ "riscv64" ], @@ -291,9 +294,9 @@ } }, "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.46.0.tgz", - "integrity": "sha512-aAf7fG23OQCey6VRPj9IeCraoYtpgtx0ZyJ1CXkPyT1wjzBE7c3xtuxHe/AdHaJfVVb/SXpSk8Gl1LzyQupSqw==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.61.0.tgz", + "integrity": "sha512-Oqi/N0OvtOVXsPKAOOhKgGH3msRYF8BLJaNBbWiupRiKoKVyc8JRKPCfarkQJC+RgP9U8raUKLe+bNwd0HUMiA==", "cpu": [ "s390x" ], @@ -308,9 +311,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.46.0.tgz", - "integrity": "sha512-q0JPsTMyJNjYrBvYFDz4WbVsafNZaPCZv4RnFypRotLqpKROtBZcEaXQW4eb9YmvLU3NckVemLJnzkSZSdmOxw==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.61.0.tgz", + "integrity": "sha512-3TKwv/ed4uwJSemAA8P9XcoqETpjQI4waquF9UilhA9Mn/dhr1PdUEXWlL74mtc6ZNfmKPA9+NEJm01nRF8CVA==", "cpu": [ "x64" ], @@ -325,9 +328,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.46.0.tgz", - "integrity": "sha512-7LsLY9Cw57GPkhSR+duI3mt9baRczK/DtHYSldQ4BEU92da9igBQNl4z7Vq5U9NNPsh1FmpKvv1q9WDtiUQR1A==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.61.0.tgz", + "integrity": "sha512-uFso4u4nLkVSlMCpgjyvWV60Gt7GvDQHnk1mmRxHIkZTMB0ljpUKwCD9FYGgN9H97x2wYl0UwEjgRZaPIuhEhw==", "cpu": [ "x64" ], @@ -342,9 +345,9 @@ } }, "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.46.0.tgz", - "integrity": "sha512-lHiBOz8Duaku7JtRNLlps3j++eOaICPZSd8FCVmTDM4DFOPT71Bjn7g6iar1z7StXlKRweUKxWUs4sA+zWGDXg==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.61.0.tgz", + "integrity": "sha512-keGLkzeOvkMpNmPp4hffXWpfoSsY6e1K8++KXD4mSSfxdvM8q9QUDsYY689TB1k6Co832DZn1MnaaVx6cIBMWQ==", "cpu": [ "arm64" ], @@ -359,9 +362,9 @@ } }, "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.46.0.tgz", - "integrity": "sha512-/5ktYUliP89RhgC37DBH1x20U5zPSZMy3cMEcO0j3793rbHP9MWsknBwQB6eozRzWmYrh0IFM/p20EbPvDlYlg==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.61.0.tgz", + "integrity": "sha512-VzsAISkFxmNhJ5LBDEL9VuH6tJsVJMtqYit2LyIUf/HLnsCe4Pg9SMOjjVQzGWt0bnpyfJ94CrqTqcpNZzK+ug==", "cpu": [ "arm64" ], @@ -376,9 +379,9 @@ } }, "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.46.0.tgz", - "integrity": "sha512-3WTnoiuIr8XvV0DIY7SN+1uJSwKf4sPpcbHfobcRT9JutGcLaef/miyBB87jxd3aqH+mS0+G5lsgHuXLUwjjpQ==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.61.0.tgz", + "integrity": "sha512-xv4t7yzwJoYaLB6Zv28B3W+j7brEjsyv50rLTAQgmxJzddce9fAMCxed8dSAkbWES0zz2J29nYK5FaTuD2YBHg==", "cpu": [ "ia32" ], @@ -393,9 +396,9 @@ } }, "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.46.0.tgz", - "integrity": "sha512-IXxiQpkYnOwNfP23vzwSfhdpxJzyiPTY7eTn6dn3DsriKddESzM8i6kfq9R7CD/PUJwCvQT22NgtygBeug3KoA==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.61.0.tgz", + "integrity": "sha512-6EZXFkqOwxdDYjIn3TSNnPk3ST5E5GiYd4FiM6UF/mCL/LZSfr6D6UygTfW3R1PCQP2quCKpCEGRlij8E3VYbg==", "cpu": [ "x64" ], @@ -410,9 +413,9 @@ } }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.61.0.tgz", - "integrity": "sha512-6eZBPgiigK5txqoVgRqxbaxiom4lM8AP8CyKPPvpzKnQ3iFRFOIDc+0AapF+qsUSwjOzr5SGk4SxQDpQhkSJMQ==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.76.0.tgz", + "integrity": "sha512-ZHIE5Zt9AsPDcY4nOlofXt0YfneEeo+QrKMPcPzLf2Z6Q8VtV2W73d7SFJ920WUwyik783u/doKCs3KXdwG+7w==", "cpu": [ "arm" ], @@ -427,9 +430,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.61.0.tgz", - "integrity": "sha512-CkwLR69MUnyv5wjzebvbbtTSUwqLxM35CXE79bHqDIK+NtKmPEUpStTcLQRZMCo4MP0qRT6TXIQVpK0ZVScnMA==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.76.0.tgz", + "integrity": "sha512-shm/ngQilHK6bs+ElJWa4oHfNj5vL1Gl/iVEJldTQjpr0/67oSgr0KUpbmcnLig5Fo0v/l6j2567A7TOL89ONA==", "cpu": [ "arm64" ], @@ -444,9 +447,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.61.0.tgz", - "integrity": "sha512-8JbefTkbmvqkqWjmQrHke+MdpgT2UghhD/ktM4FOQSpGeCgbMToJEKdl9zwhr/YWTl92i4QI1KiTwVExpcUN8A==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.76.0.tgz", + "integrity": "sha512-rvJmrAPKSQ9aWJ6wIS6CK2tJjwzfW0ApQH9qokq6sfDvmHwoyIHxHFMq7z7i7GiV6fdE6s8qvBqWKPTu8RmT6Q==", "cpu": [ "arm64" ], @@ -461,9 +464,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.61.0.tgz", - "integrity": "sha512-uWpoxDT47hTnDLcdEh5jVbso8rlTTu5o0zuqa9J8E0JAKmIWn7kGFEIB03Pycn2hd2vKxybPGLhjURy/9We5FQ==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.76.0.tgz", + "integrity": "sha512-U/zYdb7VYKGY6pA9Vd2rYl9O/HlCylcOlb5PGPvVLtg+oLGsk6H3XGKEMHKyqD3nmmtmlmwb/8SwU2vfSAtvMw==", "cpu": [ "x64" ], @@ -478,9 +481,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.61.0.tgz", - "integrity": "sha512-K/o4hEyW7flfMel0iBVznmMBt7VIMHGdjADocHKpK1DUF9erpWnJ+BSSWd2W0c8K3mPtpph+CuHzRU6CI3l9jQ==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.76.0.tgz", + "integrity": "sha512-WvKG9CAriuo0XNiFzpXjDngUZcRGFNpaK2kLyMUsnJlShxkT96u+BpJQ3KqdQwGOrvI14L6V8bAwXwAYNNY6Jg==", "cpu": [ "x64" ], @@ -495,9 +498,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.61.0.tgz", - "integrity": "sha512-P6040ZkcyweJ0Po9yEFqJCdvZnf3VNCGs1SIHgXDf8AAQNC6ID/heXQs9iSgo2FH7gKaKq32VWc59XZwL34C5Q==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.76.0.tgz", + "integrity": "sha512-qJ5+RH99TqFRq3UCDxkW0zJJu9c+OAHFY72vGlxZLEpuO+MpKo3POgqb8sYipL9KYm8XY6ofb0HsOuvY6hQNqQ==", "cpu": [ "arm" ], @@ -512,9 +515,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.61.0.tgz", - "integrity": "sha512-bwxrGCzTZkuB+THv2TQ1aTkVEfv5oz8sl+0XZZCpoYzErJD8OhPQOTA0ENPd1zJz8QsVdSzSrS2umKtPq4/JXg==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.76.0.tgz", + "integrity": "sha512-PvPCVptkgVARsucgIqFQQcSmJ6xc6GtnVB5bRBekRahTc9eObMtjHfMjy5M+C2tHt5UCMttWM9RuSk/H9NqYeg==", "cpu": [ "arm" ], @@ -529,9 +532,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.61.0.tgz", - "integrity": "sha512-vkhb9/wKguMkLlrm3FoJW/Xmdv31GgYAE+x8lxxQ+7HeOxXUySI0q36a3NTVIuQUdLzxCI1zzMGsk1o37FOe3w==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.76.0.tgz", + "integrity": "sha512-3KeFDx8Bu4HPAXbuHZOr/oHvN+QT+JQhMw/NYPz7Z071xLSsG27Jfh9PIQVEY7hk1I+jr43ExqRIeJ6VKk2yLw==", "cpu": [ "arm64" ], @@ -546,9 +549,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.61.0.tgz", - "integrity": "sha512-bl1dQh8LnVqsj6oOQAcxwbuOmNJkwc4p6o//HTBZhNTzJy21TLDwAviMqUFNUxDHkPGpmdKTSN4tWTjLryP8xg==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.76.0.tgz", + "integrity": "sha512-oPFkkKTgl0K/EIg9fQ8oA3IGcI05/Mq1en04iFa41mmNPT+6KEiByVazTOZZJiHMBBrbsns1YJ2e1Scqwzesjw==", "cpu": [ "arm64" ], @@ -563,9 +566,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.61.0.tgz", - "integrity": "sha512-QoOX6KB2IiEpyOj/HKqaxi+NQHPnOgNgnr22n9N4ANJCzXkUlj1UmeAbFb4PpqdlHIzvGDM5xZ0OKtcLq9RhiQ==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.76.0.tgz", + "integrity": "sha512-gN7yZ0eqflA5Fhf1wvHxGUltIV3FsvmB1zhNMDEK9vSHhc7E6qg9CuPeBgPZab66Tjzq6w6kHAtNEvnTHf4cyw==", "cpu": [ "ppc64" ], @@ -580,9 +583,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.61.0.tgz", - "integrity": "sha512-1TGcTerjY6p152wCof3oKElccq3xHljS/Mucp04gV/4ATpP6nO7YNnp7opEg6SHkv2a57/b4b8Ndm9znJ1/qAw==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.76.0.tgz", + "integrity": "sha512-S/HqMbn22mQrjtErUxEoS/a55u8kIeXvreIxiJu5G7Le3UecEd6SQZxrDIpuhtgaFnsY/nVra3ytP+pRljDilA==", "cpu": [ "riscv64" ], @@ -597,9 +600,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.61.0.tgz", - "integrity": "sha512-65wXEmZIrX2ADwC8i/qFL4EWLSbeuBpAm3suuX1vu4IQkKd+wLT/HU/BOl84kp91u2SxPkPDyQgu4yrqp8vwVA==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.76.0.tgz", + "integrity": "sha512-ZIga3097VJZolGZk6SrIAUokIGfRkxRlhiHDUznZptGBfwrhD7pNfD1rzEzsCwvk/1DX0A1bLz+liuNh5QKIVQ==", "cpu": [ "riscv64" ], @@ -614,9 +617,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.61.0.tgz", - "integrity": "sha512-TVvhgMvor7Qa6COeXxCJ7ENOM+lcAOGsQ0iUdPSCv2hxb9qSHLQ4XF1h50S6RE1gBOJ0WV3rNukg4JJJP1LWRA==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.76.0.tgz", + "integrity": "sha512-ZGiiA7pFzMJSyMWYZTVlPgbTsx+Vl8ihLGMIujPwaslUF7kIPPWAbVmAlTc+9lWDV+DCiB8Ikixu+lSHeOIIWQ==", "cpu": [ "s390x" ], @@ -631,9 +634,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.61.0.tgz", - "integrity": "sha512-SjpS5uYuFoDnDdZPwZE59ndF95AsY47R5MliuneTWR1pDm2CxGJaYXbKULI71t5TVfLQUWmrHEGRL9xvuq6dnA==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.76.0.tgz", + "integrity": "sha512-JLiy5WuvEBFTT6ErIFV35SLzi0R7Iri6MKU6dZbTxfIx8pndbbPs3Mj780nMipBFcPkti+okAPOJ9POKkHFEgg==", "cpu": [ "x64" ], @@ -648,9 +651,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.61.0.tgz", - "integrity": "sha512-gGfAeGD4sNJGILZbc/yKcIimO9wQnPMoYp9swAaKeEtwsSQAbU+rsdQze5SBtIP6j0QDzeYd4XSSUCRCF+LIeQ==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.76.0.tgz", + "integrity": "sha512-z7lgKQtbo/I1NIe8G5NHLesxJDv0tRSUWTpXKb9Pm3E9nKFKfO4IOSDtFroKgXtOYb0jQbcdH+0wzTyMXVes+A==", "cpu": [ "x64" ], @@ -665,9 +668,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.61.0.tgz", - "integrity": "sha512-OlVT0LrG/ct33EVtWRyR+B/othwmDWeRxfi13wUdPeb3lAT5TgTcFDcfLfarZtzB4W1nWF/zICMgYdkggX2WmQ==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.76.0.tgz", + "integrity": "sha512-JOjKymIpb9QcYfEhZsN6h4V9Ivd474W38cNIBRv6bg2TbIvogbMTH0Mg6YWW9TiRDqfcX+/Hyfsbo5vcSE5guQ==", "cpu": [ "arm64" ], @@ -682,9 +685,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.61.0.tgz", - "integrity": "sha512-vI//NZPJk6DToiovPtaiwD4iQ7kO1r5ReWQD0sOOyKRtP3E2f6jxin4uvwi3OvDzHA2EFfd7DcZl5dtkQh7g1w==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.76.0.tgz", + "integrity": "sha512-pqDWZiwcmByWUEm1NFUBNiT6aentCcaoMWJv0HbXEmuYermJ4sg8ppVrshubYP2MZ6SHccJJcpr6x469PuDFIw==", "cpu": [ "arm64" ], @@ -699,9 +702,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.61.0.tgz", - "integrity": "sha512-0ySj4/4zd2XjePs3XAQq7IigIstN4LPQZgCyigX5/ERMLjdWAJfnxcTsrtxZxuij8guJW8foXuHmhGxW0H4dDA==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.76.0.tgz", + "integrity": "sha512-Ba0O659kgMv6pwO3z9PdO+K3aMxQRaw9HnG+e6AtOfgwcKFvYilciQYBoUBmxfQvOCKZe1SwjMkuB542NkuDMQ==", "cpu": [ "ia32" ], @@ -716,9 +719,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.61.0.tgz", - "integrity": "sha512-0xgSiyeqDLDZxXoe9CVJrOx3TUVsfyoOY7cNi03JbItNcC9WCZqrSNdrAbHONxhSPaVh/lzfnDcON1RqSUMhHw==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.76.0.tgz", + "integrity": "sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA==", "cpu": [ "x64" ], @@ -744,9 +747,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.0.tgz", + "integrity": "sha512-9yB1l95IrJuNGDFdOYe79vdApdz6WWBCObE+rQ2LUliYUlcyFwSYIb2xb5/Ifw7dAtMy2ZqNyd8QTSOc7duAKw==", "cpu": [ "arm64" ], @@ -761,9 +764,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-pexNaW9ACLUOaBITOpU6qVu4VrsOFIjTv6bzgu0YUATo4eUJx0V605PxwZfndpPOn0ilqGqvGQ0M8UW0IE24jg==", "cpu": [ "arm64" ], @@ -778,9 +781,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.0.tgz", + "integrity": "sha512-NqKYaq0355ZmNMG4QGpxtEDxsc7tGDhjhCm4PpE0cwnBW+5Il95LJyq414niEiaKLVjnVHBEjSo1wngKxJNiFw==", "cpu": [ "x64" ], @@ -795,9 +798,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.0.tgz", + "integrity": "sha512-3vPoHzh6eBTz9IbB0/qZdSr0Qeks2echn+I4cHu2joV74VriPDdldswksEDzrl1mBB+oPRi+67+3Ib59paxIPQ==", "cpu": [ "x64" ], @@ -812,9 +815,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.0.tgz", + "integrity": "sha512-E6NNefZ1bUVmKJq2tJkf45J4Zyczj7qm9rUT7NY+Xo2474Y13qWAwc2tvBt0BAVbmtXR1llkxXg0Ou1jbDf2SQ==", "cpu": [ "arm" ], @@ -829,9 +832,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.0.tgz", + "integrity": "sha512-D+TgkdgM1vu+7/Fpf8+v0ARW+RXEP9Ccazgm8zQ4JFFd9Q7SrYQ2TakU5S5ihazQDgpKyAgZDOcIFsvoHmTZ8w==", "cpu": [ "arm64" ], @@ -846,9 +849,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.0.tgz", + "integrity": "sha512-wUqdwJBbAv0APN87GecstdMUtLjjNTs0hBALpxETD73mccFxdmt/XeizXDtN5RAlBwNKmI+Tg+blect2G+8IeQ==", "cpu": [ "arm64" ], @@ -863,9 +866,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.0.tgz", + "integrity": "sha512-9DtF35qR9/NrfhM4oxLplCzVVjE+KKm8Pjemi0i/sdhAWkUasjmSo8WTTubNJClhSHCfyk2yeyoXDQEDPtDAAw==", "cpu": [ "ppc64" ], @@ -880,9 +883,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.0.tgz", + "integrity": "sha512-RzuHrBh8X8Hntd2N4VR02QGEciq/9JhcZoTpR/Cee6otRrlILGCf3cg2ygHuih+ZebUnWmMrDX6ITI85btO6rQ==", "cpu": [ "s390x" ], @@ -897,9 +900,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.0.tgz", + "integrity": "sha512-MK7L0018jjh1jR3mh21G2j1zAVcpscJBlPo2z19pRjv2XOYGRhaV4LyiD8HO6nCDdZln9IFgCMIV5yt4E3klGQ==", "cpu": [ "x64" ], @@ -914,9 +917,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.0.tgz", + "integrity": "sha512-gyrxLQ9NfGb/9LoVnC4kb9miUghw1mghnkfYvNHSnVIXriabnfgGPUP4RLcJm87q3KgYz4FYUG8IDiWUT+CpSw==", "cpu": [ "x64" ], @@ -931,9 +934,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.0.tgz", + "integrity": "sha512-/6VFMQGRmrhP77KXDC+StIxGzcNp5JOIyYtw0CQ8gPlzhpiIRucYfoM5FaFamHd5BJYIdH86yfP46l1p3WdrFA==", "cpu": [ "arm64" ], @@ -948,9 +951,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.0.tgz", + "integrity": "sha512-rwdbUL465kisF24WEJLvP3JrEG6E5GRuIHt5wpMwHGERtHe4Wm2CIvtf5gTBgr2tGOHKh5NdKEAFS2VkOPE91g==", "cpu": [ "wasm32" ], @@ -958,18 +961,52 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.0.tgz", + "integrity": "sha512-+5suHwRiKGmhwyUaNT8a5QbrBvLFh2DbO910TEmGRH1aSxwrCezodvGQnulv4uiWEIv1Kq4ypRsJ5+O+ry1DiA==", "cpu": [ "arm64" ], @@ -984,9 +1021,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.0.tgz", + "integrity": "sha512-WfFv6/qGufotqBSBzBYwgpCkJBk8Nj7697LL9vTz/XWc67e0r3oewu8iMRwQj3AUL45GVD7wVsPjCsAAtW66Wg==", "cpu": [ "x64" ], @@ -1001,16 +1038,16 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -1025,13 +1062,353 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~8.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" } }, "node_modules/ansi-regex": { @@ -1078,9 +1455,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz", + "integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==", "dev": true, "license": "MIT", "dependencies": { @@ -1447,9 +1824,10 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -1554,10 +1932,20 @@ } }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -1607,13 +1995,13 @@ "license": "ISC" }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -1633,9 +2021,9 @@ } }, "node_modules/mocha": { - "version": "11.7.5", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", - "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", + "version": "11.7.6", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz", + "integrity": "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==", "dev": true, "license": "MIT", "dependencies": { @@ -1693,9 +2081,9 @@ "license": "MIT" }, "node_modules/oxfmt": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.46.0.tgz", - "integrity": "sha512-CopwJOwPAjZ9p76fCvz+mSOJTw9/NY3cSksZK3VO/bUQ8UoEcketNgUuYS0UB3p+R9XnXe7wGGXUmyFxc7QxJA==", + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.61.0.tgz", + "integrity": "sha512-DxdHBEMYpcEnHoUHjjOigUqV2TYKsvxLwUPXnVYBjgFdqrcQ/91OtwubtZ2PUodCs3sStI8R5Qw3fKNGK4e8wQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1711,31 +2099,43 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.46.0", - "@oxfmt/binding-android-arm64": "0.46.0", - "@oxfmt/binding-darwin-arm64": "0.46.0", - "@oxfmt/binding-darwin-x64": "0.46.0", - "@oxfmt/binding-freebsd-x64": "0.46.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.46.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.46.0", - "@oxfmt/binding-linux-arm64-gnu": "0.46.0", - "@oxfmt/binding-linux-arm64-musl": "0.46.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.46.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.46.0", - "@oxfmt/binding-linux-riscv64-musl": "0.46.0", - "@oxfmt/binding-linux-s390x-gnu": "0.46.0", - "@oxfmt/binding-linux-x64-gnu": "0.46.0", - "@oxfmt/binding-linux-x64-musl": "0.46.0", - "@oxfmt/binding-openharmony-arm64": "0.46.0", - "@oxfmt/binding-win32-arm64-msvc": "0.46.0", - "@oxfmt/binding-win32-ia32-msvc": "0.46.0", - "@oxfmt/binding-win32-x64-msvc": "0.46.0" + "@oxfmt/binding-android-arm-eabi": "0.61.0", + "@oxfmt/binding-android-arm64": "0.61.0", + "@oxfmt/binding-darwin-arm64": "0.61.0", + "@oxfmt/binding-darwin-x64": "0.61.0", + "@oxfmt/binding-freebsd-x64": "0.61.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.61.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.61.0", + "@oxfmt/binding-linux-arm64-gnu": "0.61.0", + "@oxfmt/binding-linux-arm64-musl": "0.61.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.61.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.61.0", + "@oxfmt/binding-linux-riscv64-musl": "0.61.0", + "@oxfmt/binding-linux-s390x-gnu": "0.61.0", + "@oxfmt/binding-linux-x64-gnu": "0.61.0", + "@oxfmt/binding-linux-x64-musl": "0.61.0", + "@oxfmt/binding-openharmony-arm64": "0.61.0", + "@oxfmt/binding-win32-arm64-msvc": "0.61.0", + "@oxfmt/binding-win32-ia32-msvc": "0.61.0", + "@oxfmt/binding-win32-x64-msvc": "0.61.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } } }, "node_modules/oxlint": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.61.0.tgz", - "integrity": "sha512-ZC0ALuhDZ6ivOFG+sy0D0pEDN49EvsId98zVlmYdkcXHsEM14m/qTNUEsUpiFiCVbpIxYtVBmmLE87nsbUHohQ==", + "version": "1.76.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.76.0.tgz", + "integrity": "sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw==", "dev": true, "license": "MIT", "bin": { @@ -1748,32 +2148,36 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.61.0", - "@oxlint/binding-android-arm64": "1.61.0", - "@oxlint/binding-darwin-arm64": "1.61.0", - "@oxlint/binding-darwin-x64": "1.61.0", - "@oxlint/binding-freebsd-x64": "1.61.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.61.0", - "@oxlint/binding-linux-arm-musleabihf": "1.61.0", - "@oxlint/binding-linux-arm64-gnu": "1.61.0", - "@oxlint/binding-linux-arm64-musl": "1.61.0", - "@oxlint/binding-linux-ppc64-gnu": "1.61.0", - "@oxlint/binding-linux-riscv64-gnu": "1.61.0", - "@oxlint/binding-linux-riscv64-musl": "1.61.0", - "@oxlint/binding-linux-s390x-gnu": "1.61.0", - "@oxlint/binding-linux-x64-gnu": "1.61.0", - "@oxlint/binding-linux-x64-musl": "1.61.0", - "@oxlint/binding-openharmony-arm64": "1.61.0", - "@oxlint/binding-win32-arm64-msvc": "1.61.0", - "@oxlint/binding-win32-ia32-msvc": "1.61.0", - "@oxlint/binding-win32-x64-msvc": "1.61.0" + "@oxlint/binding-android-arm-eabi": "1.76.0", + "@oxlint/binding-android-arm64": "1.76.0", + "@oxlint/binding-darwin-arm64": "1.76.0", + "@oxlint/binding-darwin-x64": "1.76.0", + "@oxlint/binding-freebsd-x64": "1.76.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.76.0", + "@oxlint/binding-linux-arm-musleabihf": "1.76.0", + "@oxlint/binding-linux-arm64-gnu": "1.76.0", + "@oxlint/binding-linux-arm64-musl": "1.76.0", + "@oxlint/binding-linux-ppc64-gnu": "1.76.0", + "@oxlint/binding-linux-riscv64-gnu": "1.76.0", + "@oxlint/binding-linux-riscv64-musl": "1.76.0", + "@oxlint/binding-linux-s390x-gnu": "1.76.0", + "@oxlint/binding-linux-x64-gnu": "1.76.0", + "@oxlint/binding-linux-x64-musl": "1.76.0", + "@oxlint/binding-openharmony-arm64": "1.76.0", + "@oxlint/binding-win32-arm64-msvc": "1.76.0", + "@oxlint/binding-win32-ia32-msvc": "1.76.0", + "@oxlint/binding-win32-x64-msvc": "1.76.0" }, "peerDependencies": { - "oxlint-tsgolint": ">=0.18.0" + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" }, "peerDependenciesMeta": { "oxlint-tsgolint": { "optional": true + }, + "vite-plus": { + "optional": true } } }, @@ -1861,35 +2265,35 @@ "license": "ISC" }, "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.62.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/randombytes": { @@ -1937,14 +2341,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.0.tgz", + "integrity": "sha512-u7tgm5l4Yw1iTqUL4EcYOAt7fFvCgQMLeidrnD4GALlC6aOznCjezYajgxeyKw27u0Q5N7fwgCzjVyPIWzwuBA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.140.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -1953,21 +2357,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.2.0", + "@rolldown/binding-darwin-arm64": "1.2.0", + "@rolldown/binding-darwin-x64": "1.2.0", + "@rolldown/binding-freebsd-x64": "1.2.0", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.0", + "@rolldown/binding-linux-arm64-gnu": "1.2.0", + "@rolldown/binding-linux-arm64-musl": "1.2.0", + "@rolldown/binding-linux-ppc64-gnu": "1.2.0", + "@rolldown/binding-linux-s390x-gnu": "1.2.0", + "@rolldown/binding-linux-x64-gnu": "1.2.0", + "@rolldown/binding-linux-x64-musl": "1.2.0", + "@rolldown/binding-openharmony-arm64": "1.2.0", + "@rolldown/binding-wasm32-wasi": "1.2.0", + "@rolldown/binding-win32-arm64-msvc": "1.2.0", + "@rolldown/binding-win32-x64-msvc": "1.2.0" } }, "node_modules/safe-buffer": { @@ -2185,9 +2589,9 @@ } }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "engines": { @@ -2202,23 +2606,44 @@ "license": "0BSD" }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 0a9a7dd..5076a6b 100755 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "luatests": "node --experimental-import-meta-resolve test/luatests.js", "build": "rolldown -c && tsc -d --emitDeclarationOnly --rootDir src --declarationDir dist", "clean": "rm -rf dist build", - "lint": "oxfmt --write . && oxlint --config .oxlintrc.json -D suspicious --import-plugin --node-plugin --promise-plugin --fix .", - "lint:nofix": "oxfmt --check . && oxlint --config .oxlintrc.json -D suspicious --import-plugin --node-plugin --promise-plugin ." + "lint": "oxfmt --write . && oxlint --config .oxlintrc.json --fix .", + "lint:nofix": "oxfmt --check . && oxlint --config .oxlintrc.json ." }, "files": [ "bin/*", @@ -45,13 +45,13 @@ "chai": "6.2.2", "chai-as-promised": "8.0.2", "fengari": "0.1.5", - "mocha": "11.7.5", - "oxfmt": "0.46.0", - "oxlint": "1.61.0", - "rolldown": "1.0.0-rc.17", + "mocha": "11.7.6", + "oxfmt": "0.61.0", + "oxlint": "1.76.0", + "playwright": "1.62.0", + "rolldown": "1.2.0", "tslib": "2.8.1", - "typescript": "6.0.3", - "playwright": "1.59.1" + "typescript": "7.0.2" }, "dependencies": { "@types/emscripten": "1.41.5" From 38dd18749fa788120151c3df5a1e1b5c275b8d4e Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Tue, 28 Jul 2026 19:28:41 -0300 Subject: [PATCH 41/52] fix utf-8, stdio, and api changes again --- .github/workflows/test.yml | 2 +- .oxlintrc.json | 2 +- bench/comparisons.js | 12 +- bench/steps.js | 34 +- bin/wasmoon | 157 ++++++--- package.json | 8 + src/decoration.ts | 45 ++- src/engine.ts | 120 ------- src/global.ts | 221 ------------- src/index.ts | 45 ++- src/lua.ts | 46 --- src/module.ts | 442 ++++++++++++++----------- src/runtime.ts | 70 ++++ src/state.ts | 296 +++++++++++++++++ src/thread.ts | 288 +++++++++++----- src/type-extension.ts | 12 +- src/type-extensions/error.ts | 10 +- src/type-extensions/function.ts | 40 +-- src/type-extensions/null.ts | 10 +- src/type-extensions/promise.ts | 22 +- src/type-extensions/proxy.ts | 35 +- src/type-extensions/table.ts | 6 +- src/type-extensions/userdata.ts | 26 +- src/types.ts | 212 ++++++++++-- test/browser.test.js | 34 +- test/debug.js | 4 +- test/errors.test.js | 70 ++++ test/filesystem.test.js | 2 +- test/initialization.test.js | 127 ++++++- test/limits.test.js | 75 +++++ test/luatests.js | 14 +- test/node.test.js | 176 ++++++++-- test/nodefs.test.js | 54 +-- test/promises.test.js | 56 ++-- test/{engine.test.js => state.test.js} | 394 ++++++++++++++-------- test/utils.js | 8 +- utils/build-wasm.sh | 1 - 37 files changed, 2040 insertions(+), 1136 deletions(-) delete mode 100755 src/engine.ts delete mode 100755 src/global.ts delete mode 100755 src/lua.ts create mode 100755 src/runtime.ts create mode 100755 src/state.ts create mode 100644 test/errors.test.js create mode 100644 test/limits.test.js rename test/{engine.test.js => state.test.js} (73%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a588f0b..f0519f5 100755 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: - node-version: [22, 24] + node-version: [24] steps: - uses: actions/checkout@v7 diff --git a/.oxlintrc.json b/.oxlintrc.json index 676a206..d39faa0 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -17,7 +17,7 @@ }, "overrides": [ { - "files": ["test/**"], + "files": ["**/test/**"], "rules": { "no-unused-expressions": "off" } diff --git a/bench/comparisons.js b/bench/comparisons.js index 5b6dd14..20d81d3 100644 --- a/bench/comparisons.js +++ b/bench/comparisons.js @@ -1,4 +1,4 @@ -import { Lua } from '../dist/index.js' +import { LuaRuntime } from '../dist/index.js' import fengari from 'fengari' import { isMainModule, parseBenchOptions, readBenchAsset, runBenchmarks } from './utils.js' @@ -20,11 +20,11 @@ function createWasmoonIteration(lua) { return function runWasmoonIteration() { const state = lua.createState() try { - assertStatus(state.global.lua.luaL_loadstring(state.global.address, heapsort), 'Wasmoon load') - assertStatus(state.global.lua.lua_pcallk(state.global.address, 0, 1, 0, 0, null), 'Wasmoon compile') - assertStatus(state.global.lua.lua_pcallk(state.global.address, 0, 1, 0, 0, null), 'Wasmoon execute') + assertStatus(state.lua.luaL_loadstring(state.address, heapsort), 'Wasmoon load') + assertStatus(state.lua.lua_pcallk(state.address, 0, 1, 0, 0, null), 'Wasmoon compile') + assertStatus(state.lua.lua_pcallk(state.address, 0, 1, 0, 0, null), 'Wasmoon execute') } finally { - state.global.close() + state.close() } } } @@ -36,7 +36,7 @@ function assertStatus(status, label) { } export async function runComparisonBench(options = {}) { - const lua = await Lua.load() + const lua = await LuaRuntime.load() return runBenchmarks({ title: 'Comparison benchmarks', benches: [ diff --git a/bench/steps.js b/bench/steps.js index 568a4f6..8a6d8a8 100644 --- a/bench/steps.js +++ b/bench/steps.js @@ -1,4 +1,4 @@ -import { Lua } from '../dist/index.js' +import { LuaRuntime } from '../dist/index.js' import assert from 'node:assert/strict' import { isMainModule, parseBenchOptions, readBenchAsset, runBenchmarks } from './utils.js' @@ -39,7 +39,7 @@ function createComplexObjects() { function createStateBenchmark(lua, stateOptions = {}) { return function runCreateState() { const state = lua.createState(stateOptions) - state.global.close() + state.close() } } @@ -47,11 +47,11 @@ function createRawHeapsortBenchmark(lua) { return function runRawHeapsort() { const state = lua.createState() try { - assertStatus(state.global.lua.luaL_loadstring(state.global.address, heapsort), 'Load raw heapsort') - assertStatus(state.global.lua.lua_pcallk(state.global.address, 0, 1, 0, 0, null), 'Compile raw heapsort') - assertStatus(state.global.lua.lua_pcallk(state.global.address, 0, 1, 0, 0, null), 'Execute raw heapsort') + assertStatus(state.lua.luaL_loadstring(state.address, heapsort), 'Load raw heapsort') + assertStatus(state.lua.lua_pcallk(state.address, 0, 1, 0, 0, null), 'Compile raw heapsort') + assertStatus(state.lua.lua_pcallk(state.address, 0, 1, 0, 0, null), 'Execute raw heapsort') } finally { - state.global.close() + state.close() } } } @@ -63,7 +63,7 @@ function createInteropHeapsortBenchmark(lua) { const executeHeapsort = await state.doString(heapsort) assert.equal(executeHeapsort(), 10) } finally { - state.global.close() + state.close() } } } @@ -72,9 +72,9 @@ function createInsertObjectsBenchmark(lua, stateOptions = {}) { return function runInsertObjects() { const state = lua.createState(stateOptions) try { - state.global.set('obj', createComplexObjects()) + state.set('obj', createComplexObjects()) } finally { - state.global.close() + state.close() } } } @@ -84,9 +84,9 @@ function createGetObjectsBenchmark(lua) { const state = lua.createState() try { await state.doString(luaObjectFixture) - state.global.get('obj') + state.get('obj') } finally { - state.global.close() + state.close() } } } @@ -98,19 +98,19 @@ function assertStatus(status, label) { } export async function runStepBench(options = {}) { - const lua = await Lua.load() + const lua = await LuaRuntime.load() return runBenchmarks({ title: 'Operation benchmarks', benches: [ - { name: 'Create factory', run: () => Lua.load() }, + { name: 'Create factory', run: () => LuaRuntime.load() }, { name: 'Create state', run: createStateBenchmark(lua) }, { name: 'Create state without superpowers', run: createStateBenchmark(lua, { - enableProxy: false, - injectObjects: false, - openStandardLibs: false, + objects: 'copy', + inject: false, + libs: false, }), }, { name: 'Run raw heapsort', run: createRawHeapsortBenchmark(lua) }, @@ -119,7 +119,7 @@ export async function runStepBench(options = {}) { { name: 'Insert complex objects without proxy', run: createInsertObjectsBenchmark(lua, { - enableProxy: false, + objects: 'copy', }), }, { name: 'Get complex objects', run: createGetObjectsBenchmark(lua) }, diff --git a/bin/wasmoon b/bin/wasmoon index cc0138d..3479141 100755 --- a/bin/wasmoon +++ b/bin/wasmoon @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { Lua, LuaReturn, LuaType, LUA_MULTRET, decorateProxy } from '../dist/index.js' +import { LuaRuntime, LuaReturn, LuaType, LUA_MULTRET, decorate } from '../dist/index.js' import pkg from '../package.json' with { type: 'json' } import fs from 'node:fs' import readline from 'node:readline' @@ -114,57 +114,107 @@ let inputFD = 0 if (process.stdin.isTTY) { try { inputFD = fs.openSync('/dev/tty', 'r') - } catch (e) { + } catch { // If opening /dev/tty fails (or on non-Unix systems), fallback to fd 0. inputFD = 0 } } -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: true, - removeHistoryDuplicates: true, - prompt: '> ', -}) +// Created only for the REPL: an open interface holds process.stdin in flowing mode, which both +// steals the bytes io.read is after and keeps the process alive after a script is done. +let rl = null +// Lua's io.read and the REPL share the terminal, so they can't be listening at the same time. +const pauseReadline = () => rl && !rl.closed && rl.pause() +const resumeReadline = () => rl && !rl.closed && rl.resume() + +// Bytes read past the end of a line: a single read can return more than one line, and dropping +// the remainder would lose input. +const inputChunk = Buffer.alloc(1024) +let pendingLength = 0 +let pendingOffset = 0 +// Set when a line ended on CR, so the LF of a CRLF pair doesn't start an empty line. +let skipLineFeed = false + +// Reading ahead in raw mode would strand whatever the user typed past the end of the line in here, +// where the REPL (which reads process.stdin) can never see it. +const refillInputChunk = (isRaw) => { + try { + pendingLength = fs.readSync(inputFD, inputChunk, 0, isRaw ? 1 : inputChunk.length) + } catch (err) { + // Both mean "nothing more right now", which for a blocking read is the end of the input. + if (err.code !== 'EOF' && err.code !== 'EAGAIN') { + throw err + } + pendingLength = 0 + } + pendingOffset = 0 + return pendingLength > 0 +} -const lua = await Lua.load({ +const lua = await LuaRuntime.load({ env: ignoreEnv ? undefined : process.env, fs: 'node', stdin: () => { try { - rl.pause() - - let buffer = Buffer.alloc(0xff) - let content = '' - let current = 0 - - while (true) { - const bytesRead = fs.readSync(inputFD, buffer, current, buffer.length - current) - if (bytesRead > 0) { - const charcode = buffer[current++] - if (charcode === 127) { - current = Math.max(0, current - 2) - } else { - if (charcode === 13) { - break - } + pauseReadline() + + // Raw mode is what the REPL puts the terminal in, and it comes with no echo and no + // line editing, so both have to be done by hand. Any other input already has them. + const isRaw = process.stdin.isRaw === true + const line = [] + let terminated = false + + while (!terminated) { + if (pendingOffset >= pendingLength && !refillInputChunk(isRaw)) { + break + } + + const charcode = inputChunk[pendingOffset++] + + if (skipLineFeed) { + skipLineFeed = false + if (charcode === 10) { + continue } + } - content = buffer.subarray(0, current).toString('utf8') - process.stdout.write('\x1b[2K') - process.stdout.write('\x1b[0G') - process.stdout.write(content) + if (charcode === 10 || charcode === 13) { + skipLineFeed = charcode === 13 + terminated = true + } else if (isRaw && (charcode === 127 || charcode === 8)) { + // Erase a whole character rather than a single byte, or a multi byte one would + // be left as an invalid sequence. + while (line.length > 0 && (line[line.length - 1] & 0xc0) === 0x80) { + line.pop() + } + line.pop() } else { - break + line.push(charcode) + } + + if (isRaw) { + const echo = Buffer.from(line).toString('utf8') + // A character whose remaining bytes are still on their way would flash as a + // replacement character, so leave it for the next redraw. + if (terminated) { + process.stdout.write('\n') + } else if (!echo.endsWith('�')) { + process.stdout.write(`\x1b[2K\x1b[0G${echo}`) + } } } - rl.resume() - return content + const content = Buffer.from(line).toString('utf8') + // Lua looks for the line break to know the line is complete, and reading raw mode + // input stripped it. Adding one at the end of the input would invent a line that + // isn't there. + return terminated ? `${content}\n` : content } catch (err) { // the error will not be thrown to the top, its better to log it console.error(err) + return '' + } finally { + resumeReadline() } }, }) @@ -172,12 +222,12 @@ const lua = await Lua.load({ const state = lua.createState() if (showVersion) { - console.log(`wasmoon ${pkg.version} (${state.global.get('_VERSION')})`) + console.log(`wasmoon ${pkg.version} (${state.get('_VERSION')})`) process.exit(0) } if (warnings) { - lua.module.lua_warning(state.global.address, '@on', 0) + lua.module.lua_warning(state.address, '@on', 0) } for (const module of includeModules) { @@ -185,8 +235,8 @@ for (const module of includeModules) { if (!mod) { mod = global } - const require = state.global.get('require') - state.global.set(global, require(mod)) + const require = state.get('require') + state.set(global, require(mod)) } for (const snippet of executeSnippets) { @@ -195,9 +245,10 @@ for (const snippet of executeSnippets) { // A real Lua table rather than a JS proxy, so `#arg` and `ipairs(arg)` behave like the // standalone interpreter. -state.global.set('arg', decorateProxy(scriptArgs, { proxy: false })) +state.set('arg', decorate(scriptArgs, { as: 'value' })) const isTTY = process.stdin.isTTY +const hasProgram = Boolean(scriptFile) || executeSnippets.length > 0 if (scriptFile === '-') { const input = fs.readFileSync(0, 'utf-8') @@ -206,19 +257,26 @@ if (scriptFile === '-') { await state.doFile(scriptFile) } -const shouldInteractive = isTTY && (forceInteractive || (!scriptFile && !executeSnippets.length)) +const shouldInteractive = isTTY && (forceInteractive || !hasProgram) if (shouldInteractive) { // Bypass result verification for interactive mode const loadcode = (code) => { - state.global.setTop(0) - return lua.module.luaL_loadstring(state.global.address, code) === LuaReturn.Ok + state.setTop(0) + return lua.module.luaL_loadstring(state.address, code) === LuaReturn.Ok } const version = pkg.version - const luaversion = state.global.get('_VERSION') + const luaversion = state.get('_VERSION') console.log(`Welcome to Wasmoon ${version} (${luaversion})`) console.log('Type Lua code and press Enter to execute. Ctrl+C to exit.\n') + rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + removeHistoryDuplicates: true, + prompt: '> ', + }) rl.prompt() for await (const line of rl) { @@ -226,31 +284,32 @@ if (shouldInteractive) { const loaded = loadcode(`return ${line}`) || loadcode(line) if (!loaded) { // Failed to parse - const err = state.global.getValue(-1, LuaType.String) + const err = state.getValue(-1, LuaType.String) console.log(err) rl.prompt() continue } - const result = lua.module.lua_pcallk(state.global.address, 0, LUA_MULTRET, 0, 0, null) + const result = lua.module.lua_pcallk(state.address, 0, LUA_MULTRET, 0, 0, null) if (result === LuaReturn.Ok) { - const count = state.global.getTop() + const count = state.getTop() if (count > 0) { const returnValues = [] for (let i = 1; i <= count; i++) { - returnValues.push(state.global.indexToString(i)) + returnValues.push(state.indexToString(i)) } console.log(...returnValues) } } else { - console.log(state.global.getValue(-1, LuaType.String)) + console.log(state.getValue(-1, LuaType.String)) } rl.prompt() } -} else if (!scriptFile) { - // If we're not interactive, and we did NOT run a file, +} else if (!hasProgram) { + // If we're not interactive, and we did NOT run a file or any -e snippet, // read from stdin until EOF. (Non-TTY or no -i, no file). + // With -e, stdin belongs to the script, as in the standalone interpreter. const input = fs.readFileSync(0, 'utf-8') await state.doString(input) } diff --git a/package.json b/package.json index 5076a6b..45023dd 100755 --- a/package.json +++ b/package.json @@ -21,6 +21,14 @@ "bin/*", "dist/*" ], + "engines": { + "node": ">=24" + }, + "browserslist": [ + "chrome >= 134", + "firefox >= 138", + "safari >= 26" + ], "bin": { "wasmoon": "bin/wasmoon" }, diff --git a/src/decoration.ts b/src/decoration.ts index 4d54179..ff77c32 100755 --- a/src/decoration.ts +++ b/src/decoration.ts @@ -1,14 +1,45 @@ -export interface BaseDecorationOptions { - metatable?: Record -} +/** + * How a value should be marshalled into Lua. + * + * - `'userdata'` wraps it in an opaque userdata holding a JS reference, with no members exposed. + * - `'proxy'` wraps it in a userdata whose metatable forwards index/call back into JS. + * - `'value'` skips the proxy layer, so objects are copied into Lua tables and functions are + * pushed as plain Lua functions. + * + * Leaving it undefined keeps the default behaviour for the state's `objects` option. + */ +export type DecorationTarget = 'userdata' | 'proxy' | 'value' -export class Decoration { +export class Decoration { public constructor( public target: T, - public options: K, + public options: DecorationOptions, ) {} } -export function decorate(target: unknown, options: T): Decoration { - return new Decoration(target, options) +export interface DecorationOptions { + /** Metatable to attach to the pushed value, itself decoratable to control how it is pushed. */ + metatable?: Record | Decoration + as?: DecorationTarget + /** Function only: bound as the receiver, and dropped from the argument list. */ + self?: any + /** Function only: receives the calling thread as the first argument. */ + receiveThread?: boolean + /** + * Function only: receives the argument count instead of the decoded arguments, and is + * expected to read the stack itself. This is a raw-level escape hatch. + */ + receiveArgsQuantity?: boolean +} + +/** + * Attaches marshalling instructions to a value before pushing it into Lua. + * + * ```js + * state.set('opaque', decorate(new Thing(), { as: 'userdata' })) + * state.set('bound', decorate(thing.method, { self: thing })) + * ``` + */ +export function decorate(target: T, options: DecorationOptions = {}): Decoration { + return new Decoration(target, options) } diff --git a/src/engine.ts b/src/engine.ts deleted file mode 100755 index 8381ab8..0000000 --- a/src/engine.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { CreateEngineOptions, LUA_REGISTRYINDEX } from './types' -import Global from './global' -import type LuaModule from './module' -import Thread from './thread' -import createErrorType from './type-extensions/error' -import createFunctionType from './type-extensions/function' -import createNullType from './type-extensions/null' -import createPromiseType from './type-extensions/promise' -import createProxyType from './type-extensions/proxy' -import createTableType from './type-extensions/table' -import createUserdataType from './type-extensions/userdata' - -export default class LuaEngine { - public global: Global - - public constructor( - private module: LuaModule, - { - openStandardLibs = true, - injectObjects = false, - enableProxy = true, - traceAllocations = false, - functionTimeout = undefined as number | undefined, - }: CreateEngineOptions = {}, - ) { - this.global = new Global(this.module, traceAllocations) - - // Generic handlers - These may be required to be registered for additional types. - this.global.registerTypeExtension(0, createTableType(this.global)) - this.global.registerTypeExtension(0, createFunctionType(this.global, { functionTimeout })) - - // Contains the :await functionality. - this.global.registerTypeExtension(1, createPromiseType(this.global, injectObjects)) - - if (injectObjects) { - // Should be higher priority than table since that catches generic objects along - // with userdata so it doesn't end up a userdata type. - this.global.registerTypeExtension(5, createNullType(this.global)) - } - - if (enableProxy) { - // This extension only really overrides tables and arrays. - // When a function is looked up in one of it's tables it's bound and then - // handled by the function type extension. - this.global.registerTypeExtension(3, createProxyType(this.global)) - } else { - // No need to register this when the proxy is enabled. - this.global.registerTypeExtension(1, createErrorType(this.global, injectObjects)) - } - - // Higher priority than proxied objects to allow custom user data without exposing methods. - this.global.registerTypeExtension(4, createUserdataType(this.global)) - - if (openStandardLibs) { - this.module.luaL_openlibs(this.global.address) - } - } - - /** - * Executes Lua code from a string asynchronously. - * @param script - Lua script to execute. - * @returns A Promise that resolves to the result returned by the Lua script execution. - */ - public doString(script: string): Promise { - return this.callByteCode((thread) => thread.loadString(script)) - } - - /** - * Executes Lua code from a file asynchronously. - * @param filename - Path to the Lua script file. - * @returns - A Promise that resolves to the result returned by the Lua script execution. - */ - public doFile(filename: string): Promise { - return this.callByteCode((thread) => thread.loadFile(filename)) - } - - /** - * Executes Lua code from a string synchronously. - * @param script - Lua script to execute. - * @returns - The result returned by the Lua script. - */ - public doStringSync(script: string): any { - return this.callByteCodeSync((thread) => thread.loadString(script)) - } - - /** - * Executes Lua code from a file synchronously. - * @param filename - Path to the Lua script file. - * @returns - The result returned by the Lua script. - */ - public doFileSync(filename: string): any { - return this.callByteCodeSync((thread) => thread.loadFile(filename)) - } - - private callByteCodeSync(loader: (thread: Thread) => void): any { - // Runs on the global thread, so leftovers would accumulate for the lifetime of the state. - // runSync has already converted the results to JS, and anything that has to outlive them - // holds its own registry reference. - const startStackTop = this.global.getTop() - try { - loader(this.global) - return this.global.runSync()[0] - } finally { - this.global.setTop(startStackTop) - } - } - - // WARNING: It will not wait for open handles and can potentially cause bugs if JS code tries to reference Lua after executed - private async callByteCode(loader: (thread: Thread) => void): Promise { - const thread = this.global.newThread() - // Move the thread off the global stack and into the registry as a GC anchor so it doesn't pile threads up into the stack - const ref = this.module.luaL_ref(this.global.address, LUA_REGISTRYINDEX) - try { - loader(thread) - return (await thread.run(0))[0] - } finally { - this.module.luaL_unref(this.global.address, LUA_REGISTRYINDEX, ref) - } - } -} diff --git a/src/global.ts b/src/global.ts deleted file mode 100755 index 428aed5..0000000 --- a/src/global.ts +++ /dev/null @@ -1,221 +0,0 @@ -import type LuaModule from './module' -import Thread from './thread' -import LuaTypeExtension from './type-extension' -import { LuaLibraries, LuaType } from './types' - -interface LuaMemoryStats { - memoryUsed: number - memoryMax?: number -} - -/** - * Represents the global state of the Lua engine. - */ -export default class Global extends Thread { - private memoryStats: LuaMemoryStats | undefined - private allocatorFunctionPointer: number | undefined - - /** - * Constructs a new Global instance. - * @param cmodule - The Lua module. - * @param shouldTraceAllocations - Whether to trace memory allocations. - */ - public constructor(cmodule: LuaModule, shouldTraceAllocations: boolean) { - if (shouldTraceAllocations) { - const memoryStats: LuaMemoryStats = { memoryUsed: 0 } - const allocatorFunctionPointer = cmodule._emscripten.addFunction( - (_userData: number, pointer: number, oldSize: number, newSize: number): number => { - if (newSize === 0) { - if (pointer) { - memoryStats.memoryUsed -= oldSize - cmodule._emscripten._free(pointer) - } - return 0 - } - - const endMemoryDelta = pointer ? newSize - oldSize : newSize - const endMemory = memoryStats.memoryUsed + endMemoryDelta - - if (newSize > oldSize && memoryStats.memoryMax && endMemory > memoryStats.memoryMax) { - return 0 - } - - const reallocated = cmodule._emscripten._realloc(pointer, newSize) - if (reallocated) { - memoryStats.memoryUsed = endMemory - } - return reallocated - }, - 'iiiii', - ) - - const address = cmodule.lua_newstate( - allocatorFunctionPointer, - null, - ((Date.now() >>> 0) ^ Math.floor(Math.random() * 0x100000000)) >>> 0, - ) - if (!address) { - cmodule._emscripten.removeFunction(allocatorFunctionPointer) - throw new Error('lua_newstate returned a null pointer') - } - super(cmodule, [], address) - - this.memoryStats = memoryStats - this.allocatorFunctionPointer = allocatorFunctionPointer - } else { - super(cmodule, [], cmodule.luaL_newstate()) - } - - if (this.isClosed()) { - throw new Error('Global state could not be created (probably due to lack of memory)') - } - } - - /** - * Closes the global state of the Lua engine. - */ - public close(): void { - if (this.isClosed()) { - return - } - - super.close() - - // Do this before removing the gc to force. - // Here rather than in the threads because you don't - // actually close threads, just pop them. Only the top-level - // lua state needs closing. - this.lua.lua_close(this.address) - - if (this.allocatorFunctionPointer) { - this.lua._emscripten.removeFunction(this.allocatorFunctionPointer) - } - - for (const wrapper of this.typeExtensions) { - wrapper.extension.close() - } - } - - /** - * Registers a type extension for Lua objects. - * Higher priority is more important and will be evaluated first. - * Allows library users to specify custom types - * @param priority - Priority of the type extension. - * @param extension - The type extension to register. - */ - public registerTypeExtension(priority: number, extension: LuaTypeExtension): void { - this.typeExtensions.push({ extension, priority }) - this.typeExtensions.sort((a, b) => b.priority - a.priority) - } - - /** - * Loads a default Lua library. - * @param library - The Lua library to load. - */ - public loadLibrary(library: LuaLibraries): void { - switch (library) { - case LuaLibraries.Base: - this.lua.luaopen_base(this.address) - break - case LuaLibraries.Coroutine: - this.lua.luaopen_coroutine(this.address) - break - case LuaLibraries.Table: - this.lua.luaopen_table(this.address) - break - case LuaLibraries.IO: - this.lua.luaopen_io(this.address) - break - case LuaLibraries.OS: - this.lua.luaopen_os(this.address) - break - case LuaLibraries.String: - this.lua.luaopen_string(this.address) - break - case LuaLibraries.UTF8: - this.lua.luaopen_utf8(this.address) - break - case LuaLibraries.Math: - this.lua.luaopen_math(this.address) - break - case LuaLibraries.Debug: - this.lua.luaopen_debug(this.address) - break - case LuaLibraries.Package: - this.lua.luaopen_package(this.address) - break - } - this.lua.lua_setglobal(this.address, library) - } - - /** - * Retrieves the value of a global variable. - * @param name - The name of the global variable. - * @returns - The value of the global variable. - */ - public get(name: string): any { - const type = this.lua.lua_getglobal(this.address, name) - const value = this.getValue(-1, type) - this.pop() - return value - } - - /** - * Sets the value of a global variable. - * @param name - The name of the global variable. - * @param value - The value to set for the global variable. - */ - public set(name: string, value: unknown): void { - this.pushValue(value) - this.lua.lua_setglobal(this.address, name) - } - - public getTable(name: string, callback: (index: number) => void): void { - const startStackTop = this.getTop() - const type = this.lua.lua_getglobal(this.address, name) - try { - if (type !== LuaType.Table) { - throw new TypeError(`Unexpected type in ${name}. Expected ${LuaType[LuaType.Table]}. Got ${LuaType[type]}.`) - } - callback(startStackTop + 1) - } finally { - // +1 for the table - if (this.getTop() !== startStackTop + 1) { - console.warn(`getTable: expected stack size ${startStackTop + 1} got ${this.getTop()}`) - } - this.setTop(startStackTop) - } - } - - /** - * 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. - * @returns - The amount of memory used in bytes. - */ - public getMemoryUsed(): number { - return this.getMemoryStatsRef().memoryUsed - } - - /** - * 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. - * @returns - The maximum memory allowed in bytes, or undefined if not set. - */ - public getMemoryMax(): number | undefined { - return this.getMemoryStatsRef().memoryMax - } - - /** - * 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. - * @param max - The maximum memory allowed in bytes, or undefined for unlimited. - */ - public setMemoryMax(max: number | undefined): void { - this.getMemoryStatsRef().memoryMax = max - } - - private getMemoryStatsRef(): LuaMemoryStats { - if (!this.memoryStats) { - throw new Error('Memory allocations is not being traced, please build engine with { traceAllocations: true }') - } - - return this.memoryStats - } -} diff --git a/src/index.ts b/src/index.ts index 11cb4a6..bd40719 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,41 @@ -export { default as LuaEngine } from './engine' -export { default as Lua } from './lua' -export { default as LuaGlobal } from './global' +export { default as LuaRuntime } from './runtime' +export { default as LuaState, type LuaMemory } from './state' +export { default as LuaThread } from './thread' export { default as LuaMultiReturn } from './multireturn' export { default as LuaRawResult } from './raw-result' -export { default as LuaThread } from './thread' +export { decorate, Decoration, type DecorationOptions, type DecorationTarget } from './decoration' // Export the underlying bindings to allow users to just // use the bindings rather than the wrappers. -export { decorate, Decoration } from './decoration' export { default as LuaModule } from './module' export { default as LuaTypeExtension } from './type-extension' -export { decorateFunction } from './type-extensions/function' -export { decorateProxy } from './type-extensions/proxy' -export { decorateUserdata } from './type-extensions/userdata' -export * from './types' +// Named rather than `export *`, so the library bitmask helpers and the warn default stay internal. +export { + LuaError, + LuaInterruptError, + LuaTimeoutError, + LuaInstructionLimitError, + LuaAbortError, + LuaReturn, + LuaType, + LuaEventCodes, + LuaEventMasks, + LUA_MULTRET, + LUA_REGISTRYINDEX, + LUAI_MAXSTACK, + LUA_LIB_BITS, + type LuaAddress, + type LuaLibName, + type LuaLoadMode, + type LuaWarnHandler, + type CreateStateOptions, + type LuaMemoryOptions, + type LuaLimitOptions, + type LuaRunOptions, + type LuaLoadOptions, + type LuaDoOptions, + type LuaThreadLimits, + type LuaResumeResult, +} from './types' -import Lua from './lua' -export default Lua +import LuaRuntime from './runtime' +export default LuaRuntime diff --git a/src/lua.ts b/src/lua.ts deleted file mode 100755 index ac799de..0000000 --- a/src/lua.ts +++ /dev/null @@ -1,46 +0,0 @@ -import LuaModule from './module' -import LuaEngine from './engine' -import { CreateEngineOptions } from './types' - -/** - * Represents a factory for creating and configuring Lua engines. - */ -export default class Lua { - /** - * Constructs a new LuaFactory instance. - * @param opts.wasmFile - Custom URI for the Lua WebAssembly module. - * @param opts.env - Environment variables for the Lua engine. - * @param opts.stdin - Standard input for the Lua engine. - * @param opts.fs - File system that should be used for the Lua engine. - * @param opts.stdout - Standard output for the Lua engine. - * @param opts.stderr - Standard error for the Lua engine. - */ - public static async load(luaModuleOpts: Parameters[0] = {}): Promise { - return new Lua(await LuaModule.initialize(luaModuleOpts)) - } - - public constructor(public readonly module: LuaModule) {} - - public createState(stateOpts: CreateEngineOptions = {}): LuaEngine { - return new LuaEngine(this.module, stateOpts) - } - - /** - * Mounts a file in the Lua environment synchronously. - * @param path - Path to the file in the Lua environment. - * @param content - Content of the file to be mounted. - */ - public mountFile(path: string, content: string | ArrayBufferView): void { - const dirname = this.module._emscripten.PATH.dirname(path) - this.module._emscripten.FS.mkdirTree(dirname) - this.module._emscripten.FS.writeFile(path, content) - } - - public get filesystem(): typeof this.module._emscripten.FS { - return this.module._emscripten.FS - } - - public get path(): typeof this.module._emscripten.PATH { - return this.module._emscripten.PATH - } -} diff --git a/src/module.ts b/src/module.ts index f9f92a0..4f46613 100755 --- a/src/module.ts +++ b/src/module.ts @@ -1,5 +1,5 @@ import initWasmModule from '../build/glue.js' -import { LUA_REGISTRYINDEX, LuaReturn, LuaState, LuaType, PointerSize } from './types.js' +import { defaultWarnHandler, LUA_REGISTRYINDEX, LuaAddress, LuaReturn, LuaType, LuaWarnHandler, PointerSize } from './types.js' // A rolldown plugin will resolve this to the current version on package.json import version from 'package-version' @@ -24,12 +24,16 @@ interface LuaEmscriptenModule extends EmscriptenModule { stringToNewUTF8: typeof stringToNewUTF8 lengthBytesUTF8: typeof lengthBytesUTF8 stringToUTF8: typeof stringToUTF8 - intArrayFromString: typeof intArrayFromString UTF8ToString: typeof UTF8ToString ENV: EnvironmentVariables _realloc: (pointer: number, size: number) => number } +// One-shot conversions, so a single stateless pair is shared by every module. Streaming output +// keeps its own decoder in createOutputWriter. +const textDecoder = new TextDecoder() +const textEncoder = new TextEncoder() + // Above this a dedicated allocation is used, so one huge string cannot permanently retain the // scratch buffer. const REUSABLE_STRING_BUFFER_LIMIT = 64 * 1024 @@ -47,10 +51,16 @@ export default class LuaModule { env?: EnvironmentVariables fs?: 'node' | 'memory' fsMountPaths?: string[] - stdin?: () => string + // Called once per read, and an empty string (or nothing) means EOF. + stdin?: () => string | null | undefined + // Called with each completed line, without the line break, and with a partial line when + // Lua flushes or suspends in the middle of one. stdout?: (content: string) => void stderr?: (content: string) => void + /** Where load time diagnostics go. Defaults to `console.warn`. */ + onWarn?: LuaWarnHandler }): Promise { + const warn = opts.onWarn ?? defaultWarnHandler const isBrowser = (typeof window === 'object' && typeof window.document !== 'undefined') || (typeof self === 'object' && self?.constructor?.name === 'DedicatedWorkerGlobalScope') @@ -115,7 +125,7 @@ export default class LuaModule { // (string .code) and Emscripten ErrnoError (numeric .errno) const isIgnorableError = ['EACCES', 'EPERM', 'ENOENT'].includes(err?.code) || err?.name === 'ErrnoError' if (!isIgnorableError) { - console.warn(`Failed to mount ${dir}:`, err) + warn(`Failed to mount ${dir}`, err) } } } @@ -128,28 +138,8 @@ export default class LuaModule { } if (opts.stdin || opts.stdout || opts.stderr) { - let bufferedInput: number[] | undefined initializedModule.FS.init( - opts.stdin - ? () => { - if (!bufferedInput) { - const input = opts.stdin?.() - if (typeof input === 'string') { - bufferedInput = initializedModule.intArrayFromString(input, true).concat([0]) - } else { - throw new Error('stdin must return a string') - } - } - - if (bufferedInput!.length === 0) { - bufferedInput = undefined - return null - } - - const item = bufferedInput!.shift() - return !item || item === 0 ? null : item - } - : null, + createInputReader(opts.stdin), createOutputWriter(opts.stdout), createOutputWriter(opts.stderr), ) @@ -161,159 +151,159 @@ export default class LuaModule { public _emscripten: LuaEmscriptenModule - public luaL_checkversion_: (L: LuaState, ver: number, sz: number) => void - public luaL_getmetafield: (L: LuaState, obj: number, e: string | null) => LuaType - public luaL_callmeta: (L: LuaState, obj: number, e: string | null) => number - public luaL_argerror: (L: LuaState, arg: number, extramsg: string | null) => number - public luaL_typeerror: (L: LuaState, arg: number, tname: string | null) => number - public luaL_checklstring: (L: LuaState, arg: number, l: number | null) => string - public luaL_optlstring: (L: LuaState, arg: number, def: string | null, l: number | null) => string - public luaL_checknumber: (L: LuaState, arg: number) => number - public luaL_optnumber: (L: LuaState, arg: number, def: number) => number - public luaL_checkinteger: (L: LuaState, arg: number) => number - public luaL_optinteger: (L: LuaState, arg: number, def: number) => number - public luaL_checkstack: (L: LuaState, sz: number, msg: string | null) => void - public luaL_checktype: (L: LuaState, arg: number, t: number) => void - public luaL_checkany: (L: LuaState, arg: number) => void - public luaL_newmetatable: (L: LuaState, tname: string | null) => number - public luaL_setmetatable: (L: LuaState, tname: string | null) => void - public luaL_testudata: (L: LuaState, ud: number, tname: string | null) => number - public luaL_checkudata: (L: LuaState, ud: number, tname: string | null) => number - public luaL_where: (L: LuaState, lvl: number) => void - public luaL_fileresult: (L: LuaState, stat: number, fname: string | null) => number - public luaL_execresult: (L: LuaState, stat: number) => number - public luaL_ref: (L: LuaState, t: number) => number - public luaL_unref: (L: LuaState, t: number, ref: number) => void - public luaL_loadfilex: (L: LuaState, filename: string | null, mode: string | null) => LuaReturn + public luaL_checkversion_: (L: LuaAddress, ver: number, sz: number) => void + public luaL_getmetafield: (L: LuaAddress, obj: number, e: string | null) => LuaType + public luaL_callmeta: (L: LuaAddress, obj: number, e: string | null) => number + public luaL_argerror: (L: LuaAddress, arg: number, extramsg: string | null) => number + public luaL_typeerror: (L: LuaAddress, arg: number, tname: string | null) => number + public luaL_checklstring: (L: LuaAddress, arg: number, l: number | null) => string + public luaL_optlstring: (L: LuaAddress, arg: number, def: string | null, l: number | null) => string + public luaL_checknumber: (L: LuaAddress, arg: number) => number + public luaL_optnumber: (L: LuaAddress, arg: number, def: number) => number + public luaL_checkinteger: (L: LuaAddress, arg: number) => number + public luaL_optinteger: (L: LuaAddress, arg: number, def: number) => number + public luaL_checkstack: (L: LuaAddress, sz: number, msg: string | null) => void + public luaL_checktype: (L: LuaAddress, arg: number, t: number) => void + public luaL_checkany: (L: LuaAddress, arg: number) => void + public luaL_newmetatable: (L: LuaAddress, tname: string | null) => number + public luaL_setmetatable: (L: LuaAddress, tname: string | null) => void + public luaL_testudata: (L: LuaAddress, ud: number, tname: string | null) => number + public luaL_checkudata: (L: LuaAddress, ud: number, tname: string | null) => number + public luaL_where: (L: LuaAddress, lvl: number) => void + public luaL_fileresult: (L: LuaAddress, stat: number, fname: string | null) => number + public luaL_execresult: (L: LuaAddress, stat: number) => number + public luaL_ref: (L: LuaAddress, t: number) => number + public luaL_unref: (L: LuaAddress, t: number, ref: number) => void + public luaL_loadfilex: (L: LuaAddress, filename: string | null, mode: string | null) => LuaReturn public luaL_loadbufferx: ( - L: LuaState, + L: LuaAddress, buff: string | number | null, sz: number, name: string | number | null, mode: string | null, ) => LuaReturn - public luaL_loadstring: (L: LuaState, s: string | null) => LuaReturn - public luaL_newstate: () => LuaState - public luaL_len: (L: LuaState, idx: number) => number + public luaL_loadstring: (L: LuaAddress, s: string | null) => LuaReturn + public luaL_newstate: () => LuaAddress + public luaL_len: (L: LuaAddress, idx: number) => number public luaL_addgsub: (b: number | null, s: string | null, p: string | null, r: string | null) => void - public luaL_gsub: (L: LuaState, s: string | null, p: string | null, r: string | null) => string - public luaL_setfuncs: (L: LuaState, l: number | null, nup: number) => void - public luaL_getsubtable: (L: LuaState, idx: number, fname: string | null) => number - public luaL_traceback: (L: LuaState, L1: LuaState, msg: string | null, level: number) => void - public luaL_requiref: (L: LuaState, modname: string | null, openf: number, glb: number) => void - public luaL_openselectedlibs: (L: LuaState, load: number, preload: number) => void - public luaL_buffinit: (L: LuaState, B: number | null) => void + public luaL_gsub: (L: LuaAddress, s: string | null, p: string | null, r: string | null) => string + public luaL_setfuncs: (L: LuaAddress, l: number | null, nup: number) => void + public luaL_getsubtable: (L: LuaAddress, idx: number, fname: string | null) => number + public luaL_traceback: (L: LuaAddress, L1: LuaAddress, msg: string | null, level: number) => void + public luaL_requiref: (L: LuaAddress, modname: string | null, openf: number, glb: number) => void + public luaL_openselectedlibs: (L: LuaAddress, load: number, preload: number) => void + public luaL_buffinit: (L: LuaAddress, B: number | null) => void public luaL_prepbuffsize: (B: number | null, sz: number) => string public luaL_addlstring: (B: number | null, s: string | null, l: number) => void public luaL_addstring: (B: number | null, s: string | null) => void public luaL_addvalue: (B: number | null) => void public luaL_pushresult: (B: number | null) => void public luaL_pushresultsize: (B: number | null, sz: number) => void - public luaL_buffinitsize: (L: LuaState, B: number | null, sz: number) => string - public lua_newstate: (f: number | null, ud: number | null, seed: number) => LuaState - public lua_close: (L: LuaState) => void - public lua_newthread: (L: LuaState) => LuaState - public lua_closethread: (L: LuaState, from: LuaState | null) => LuaReturn - public lua_resetthread: (L: LuaState) => LuaReturn - public lua_atpanic: (L: LuaState, panicf: number) => number - public lua_version: (L: LuaState) => number - public lua_absindex: (L: LuaState, idx: number) => number - public lua_gettop: (L: LuaState) => number - public lua_settop: (L: LuaState, idx: number) => void - public lua_pushvalue: (L: LuaState, idx: number) => void - public lua_rotate: (L: LuaState, idx: number, n: number) => void - public lua_copy: (L: LuaState, fromidx: number, toidx: number) => void - public lua_checkstack: (L: LuaState, n: number) => number - public lua_xmove: (from: LuaState, to: LuaState, n: number) => void - public lua_isnumber: (L: LuaState, idx: number) => number - public lua_isstring: (L: LuaState, idx: number) => number - public lua_iscfunction: (L: LuaState, idx: number) => number - public lua_isinteger: (L: LuaState, idx: number) => number - public lua_isuserdata: (L: LuaState, idx: number) => number - public lua_type: (L: LuaState, idx: number) => LuaType - public lua_typename: (L: LuaState, tp: number) => string - public lua_tonumberx: (L: LuaState, idx: number, isnum: number | null) => number - public lua_tointegerx: (L: LuaState, idx: number, isnum: number | null) => bigint - public lua_toboolean: (L: LuaState, idx: number) => number - public lua_rawlen: (L: LuaState, idx: number) => bigint - public lua_tocfunction: (L: LuaState, idx: number) => number - public lua_touserdata: (L: LuaState, idx: number) => number - public lua_tothread: (L: LuaState, idx: number) => LuaState - public lua_topointer: (L: LuaState, idx: number) => number - public lua_arith: (L: LuaState, op: number) => void - public lua_rawequal: (L: LuaState, idx1: number, idx2: number) => number - public lua_compare: (L: LuaState, idx1: number, idx2: number, op: number) => number - public lua_pushnil: (L: LuaState) => void - public lua_pushnumber: (L: LuaState, n: number) => void - public lua_pushinteger: (L: LuaState, n: bigint) => void - public lua_pushlstring: (L: LuaState, s: number, len: number) => void - public lua_pushcclosure: (L: LuaState, fn: number, n: number) => void - public lua_pushboolean: (L: LuaState, b: number) => void - public lua_pushlightuserdata: (L: LuaState, p: number | null) => void - public lua_pushthread: (L: LuaState) => number - public lua_getglobal: (L: LuaState, name: string | null) => LuaType - public lua_gettable: (L: LuaState, idx: number) => LuaType - public lua_getfield: (L: LuaState, idx: number, k: string | null) => LuaType - public lua_geti: (L: LuaState, idx: number, n: bigint) => LuaType - public lua_rawget: (L: LuaState, idx: number) => number - public lua_rawgeti: (L: LuaState, idx: number, n: bigint) => LuaType - public lua_rawgetp: (L: LuaState, idx: number, p: number | null) => LuaType - public lua_createtable: (L: LuaState, narr: number, nrec: number) => void - public lua_newuserdatauv: (L: LuaState, sz: number, nuvalue: number) => number - public lua_getmetatable: (L: LuaState, objindex: number) => number - public lua_getiuservalue: (L: LuaState, idx: number, n: number) => LuaType - public lua_setglobal: (L: LuaState, name: string | null) => void - public lua_settable: (L: LuaState, idx: number) => void - public lua_setfield: (L: LuaState, idx: number, k: string | null) => void - public lua_seti: (L: LuaState, idx: number, n: bigint) => void - public lua_rawset: (L: LuaState, idx: number) => void - public lua_rawseti: (L: LuaState, idx: number, n: bigint) => void - public lua_rawsetp: (L: LuaState, idx: number, p: number | null) => void - public lua_setmetatable: (L: LuaState, objindex: number) => number - public lua_setiuservalue: (L: LuaState, idx: number, n: number) => number - public lua_callk: (L: LuaState, nargs: number, nresults: number, ctx: number, k: number | null) => void - public lua_pcallk: (L: LuaState, nargs: number, nresults: number, errfunc: number, ctx: number, k: number | null) => number - public lua_load: (L: LuaState, reader: number | null, dt: number | null, chunkname: string | null, mode: string | null) => LuaReturn - public lua_dump: (L: LuaState, writer: number | null, data: number | null, strip: number) => number - public lua_yieldk: (L: LuaState, nresults: number, ctx: number, k: number | null) => number - public lua_resume: (L: LuaState, from: LuaState | null, narg: number, nres: number | null) => LuaReturn - public lua_status: (L: LuaState) => LuaReturn - public lua_isyieldable: (L: LuaState) => number - public lua_setwarnf: (L: LuaState, f: number | null, ud: number | null) => void - public lua_warning: (L: LuaState, msg: string | null, tocont: number) => void - public lua_error: (L: LuaState) => number - public lua_next: (L: LuaState, idx: number) => number - public lua_concat: (L: LuaState, n: number) => void - public lua_len: (L: LuaState, idx: number) => void - public lua_stringtonumber: (L: LuaState, s: string | null) => number - public lua_getallocf: (L: LuaState, ud: number | null) => number - public lua_setallocf: (L: LuaState, f: number | null, ud: number | null) => void - public lua_toclose: (L: LuaState, idx: number) => void - public lua_closeslot: (L: LuaState, idx: number) => void - public lua_getstack: (L: LuaState, level: number, ar: number | null) => number - public lua_getinfo: (L: LuaState, what: string | null, ar: number | null) => number - public lua_getlocal: (L: LuaState, ar: number | null, n: number) => string - public lua_setlocal: (L: LuaState, ar: number | null, n: number) => string - public lua_getupvalue: (L: LuaState, funcindex: number, n: number) => string - public lua_setupvalue: (L: LuaState, funcindex: number, n: number) => string - public lua_upvalueid: (L: LuaState, fidx: number, n: number) => number - public lua_upvaluejoin: (L: LuaState, fidx1: number, n1: number, fidx2: number, n2: number) => void - public lua_sethook: (L: LuaState, func: number | null, mask: number, count: number) => void - public lua_gethook: (L: LuaState) => number - public lua_gethookmask: (L: LuaState) => number - public lua_gethookcount: (L: LuaState) => number - public lua_setcstacklimit: (_L: LuaState, _limit: number) => number - public luaopen_base: (L: LuaState) => number - public luaopen_coroutine: (L: LuaState) => number - public luaopen_table: (L: LuaState) => number - public luaopen_io: (L: LuaState) => number - public luaopen_os: (L: LuaState) => number - public luaopen_string: (L: LuaState) => number - public luaopen_utf8: (L: LuaState) => number - public luaopen_math: (L: LuaState) => number - public luaopen_debug: (L: LuaState) => number - public luaopen_package: (L: LuaState) => number - public luaL_openlibs: (L: LuaState) => void + public luaL_buffinitsize: (L: LuaAddress, B: number | null, sz: number) => string + public lua_newstate: (f: number | null, ud: number | null, seed: number) => LuaAddress + public lua_close: (L: LuaAddress) => void + public lua_newthread: (L: LuaAddress) => LuaAddress + public lua_closethread: (L: LuaAddress, from: LuaAddress | null) => LuaReturn + public lua_resetthread: (L: LuaAddress) => LuaReturn + public lua_atpanic: (L: LuaAddress, panicf: number) => number + public lua_version: (L: LuaAddress) => number + public lua_absindex: (L: LuaAddress, idx: number) => number + public lua_gettop: (L: LuaAddress) => number + public lua_settop: (L: LuaAddress, idx: number) => void + public lua_pushvalue: (L: LuaAddress, idx: number) => void + public lua_rotate: (L: LuaAddress, idx: number, n: number) => void + public lua_copy: (L: LuaAddress, fromidx: number, toidx: number) => void + public lua_checkstack: (L: LuaAddress, n: number) => number + public lua_xmove: (from: LuaAddress, to: LuaAddress, n: number) => void + public lua_isnumber: (L: LuaAddress, idx: number) => number + public lua_isstring: (L: LuaAddress, idx: number) => number + public lua_iscfunction: (L: LuaAddress, idx: number) => number + public lua_isinteger: (L: LuaAddress, idx: number) => number + public lua_isuserdata: (L: LuaAddress, idx: number) => number + public lua_type: (L: LuaAddress, idx: number) => LuaType + public lua_typename: (L: LuaAddress, tp: number) => string + public lua_tonumberx: (L: LuaAddress, idx: number, isnum: number | null) => number + public lua_tointegerx: (L: LuaAddress, idx: number, isnum: number | null) => bigint + public lua_toboolean: (L: LuaAddress, idx: number) => number + public lua_rawlen: (L: LuaAddress, idx: number) => bigint + public lua_tocfunction: (L: LuaAddress, idx: number) => number + public lua_touserdata: (L: LuaAddress, idx: number) => number + public lua_tothread: (L: LuaAddress, idx: number) => LuaAddress + public lua_topointer: (L: LuaAddress, idx: number) => number + public lua_arith: (L: LuaAddress, op: number) => void + public lua_rawequal: (L: LuaAddress, idx1: number, idx2: number) => number + public lua_compare: (L: LuaAddress, idx1: number, idx2: number, op: number) => number + public lua_pushnil: (L: LuaAddress) => void + public lua_pushnumber: (L: LuaAddress, n: number) => void + public lua_pushinteger: (L: LuaAddress, n: bigint) => void + public lua_pushlstring: (L: LuaAddress, s: number, len: number) => void + public lua_pushcclosure: (L: LuaAddress, fn: number, n: number) => void + public lua_pushboolean: (L: LuaAddress, b: number) => void + public lua_pushlightuserdata: (L: LuaAddress, p: number | null) => void + public lua_pushthread: (L: LuaAddress) => number + public lua_getglobal: (L: LuaAddress, name: string | null) => LuaType + public lua_gettable: (L: LuaAddress, idx: number) => LuaType + public lua_getfield: (L: LuaAddress, idx: number, k: string | null) => LuaType + public lua_geti: (L: LuaAddress, idx: number, n: bigint) => LuaType + public lua_rawget: (L: LuaAddress, idx: number) => number + public lua_rawgeti: (L: LuaAddress, idx: number, n: bigint) => LuaType + public lua_rawgetp: (L: LuaAddress, idx: number, p: number | null) => LuaType + public lua_createtable: (L: LuaAddress, narr: number, nrec: number) => void + public lua_newuserdatauv: (L: LuaAddress, sz: number, nuvalue: number) => number + public lua_getmetatable: (L: LuaAddress, objindex: number) => number + public lua_getiuservalue: (L: LuaAddress, idx: number, n: number) => LuaType + public lua_setglobal: (L: LuaAddress, name: string | null) => void + public lua_settable: (L: LuaAddress, idx: number) => void + public lua_setfield: (L: LuaAddress, idx: number, k: string | null) => void + public lua_seti: (L: LuaAddress, idx: number, n: bigint) => void + public lua_rawset: (L: LuaAddress, idx: number) => void + public lua_rawseti: (L: LuaAddress, idx: number, n: bigint) => void + public lua_rawsetp: (L: LuaAddress, idx: number, p: number | null) => void + public lua_setmetatable: (L: LuaAddress, objindex: number) => number + public lua_setiuservalue: (L: LuaAddress, idx: number, n: number) => number + public lua_callk: (L: LuaAddress, nargs: number, nresults: number, ctx: number, k: number | null) => void + public lua_pcallk: (L: LuaAddress, nargs: number, nresults: number, errfunc: number, ctx: number, k: number | null) => number + public lua_load: (L: LuaAddress, reader: number | null, dt: number | null, chunkname: string | null, mode: string | null) => LuaReturn + public lua_dump: (L: LuaAddress, writer: number | null, data: number | null, strip: number) => number + public lua_yieldk: (L: LuaAddress, nresults: number, ctx: number, k: number | null) => number + public lua_resume: (L: LuaAddress, from: LuaAddress | null, narg: number, nres: number | null) => LuaReturn + public lua_status: (L: LuaAddress) => LuaReturn + public lua_isyieldable: (L: LuaAddress) => number + public lua_setwarnf: (L: LuaAddress, f: number | null, ud: number | null) => void + public lua_warning: (L: LuaAddress, msg: string | null, tocont: number) => void + public lua_error: (L: LuaAddress) => number + public lua_next: (L: LuaAddress, idx: number) => number + public lua_concat: (L: LuaAddress, n: number) => void + public lua_len: (L: LuaAddress, idx: number) => void + public lua_stringtonumber: (L: LuaAddress, s: string | null) => number + public lua_getallocf: (L: LuaAddress, ud: number | null) => number + public lua_setallocf: (L: LuaAddress, f: number | null, ud: number | null) => void + public lua_toclose: (L: LuaAddress, idx: number) => void + public lua_closeslot: (L: LuaAddress, idx: number) => void + public lua_getstack: (L: LuaAddress, level: number, ar: number | null) => number + public lua_getinfo: (L: LuaAddress, what: string | null, ar: number | null) => number + public lua_getlocal: (L: LuaAddress, ar: number | null, n: number) => string + public lua_setlocal: (L: LuaAddress, ar: number | null, n: number) => string + public lua_getupvalue: (L: LuaAddress, funcindex: number, n: number) => string + public lua_setupvalue: (L: LuaAddress, funcindex: number, n: number) => string + public lua_upvalueid: (L: LuaAddress, fidx: number, n: number) => number + public lua_upvaluejoin: (L: LuaAddress, fidx1: number, n1: number, fidx2: number, n2: number) => void + public lua_sethook: (L: LuaAddress, func: number | null, mask: number, count: number) => void + public lua_gethook: (L: LuaAddress) => number + public lua_gethookmask: (L: LuaAddress) => number + public lua_gethookcount: (L: LuaAddress) => number + public lua_setcstacklimit: (_L: LuaAddress, _limit: number) => number + public luaopen_base: (L: LuaAddress) => number + public luaopen_coroutine: (L: LuaAddress) => number + public luaopen_table: (L: LuaAddress) => number + public luaopen_io: (L: LuaAddress) => number + public luaopen_os: (L: LuaAddress) => number + public luaopen_string: (L: LuaAddress) => number + public luaopen_utf8: (L: LuaAddress) => number + public luaopen_math: (L: LuaAddress) => number + public luaopen_debug: (L: LuaAddress) => number + public luaopen_package: (L: LuaAddress) => number + public luaL_openlibs: (L: LuaAddress) => void private referenceTracker = new WeakMap() private referenceMap = new Map() @@ -322,12 +312,10 @@ export default class LuaModule { // Lua strings are byte arrays that may contain NUL, so they cannot go through Emscripten's // NUL-terminated string marshalling. These work on pointers and explicit lengths instead. - private readonly rawLuaToLString: (L: LuaState, idx: number, len: number) => number - private readonly rawLuaLToLString: (L: LuaState, idx: number, len: number) => number - private readonly rawLuaPushString: (L: LuaState, s: number) => number + private readonly rawLuaToLString: (L: LuaAddress, idx: number, len: number) => number + private readonly rawLuaLToLString: (L: LuaAddress, idx: number, len: number) => number + private readonly rawLuaPushString: (L: LuaAddress, s: number) => number - private readonly textDecoder = new TextDecoder() - private readonly textEncoder = new TextEncoder() // C writes it immediately before returning and we read it straight after with no interleaving // await, so a single shared slot stays reentrancy safe. private readonly sizeScratch: number @@ -499,16 +487,16 @@ export default class LuaModule { * Bytes that aren't valid UTF-8 are replaced with U+FFFD. Use {@link lua_tobytes} when the * value holds arbitrary binary data (`string.dump`, `string.pack`, ciphertext, ...). */ - public lua_tolstring(L: LuaState, idx: number, len: number | null = null): string { + public lua_tolstring(L: LuaAddress, idx: number, len: number | null = null): string { return this.toLString(this.rawLuaToLString, L, idx, len) } /** Goes through the `__tostring` metamethod, which leaves the result on the stack. */ - public luaL_tolstring(L: LuaState, idx: number, len: number | null = null): string { + public luaL_tolstring(L: LuaAddress, idx: number, len: number | null = null): string { return this.toLString(this.rawLuaLToLString, L, idx, len) } - public lua_tobytes(L: LuaState, idx: number): Uint8Array | undefined { + public lua_tobytes(L: LuaAddress, idx: number): Uint8Array | undefined { const pointer = this.rawLuaToLString(L, idx, this.sizeScratch) if (!pointer) { return undefined @@ -518,7 +506,7 @@ export default class LuaModule { } /** A number is a pointer to a NUL-terminated C string, and `null` pushes nil, as in C. */ - public lua_pushstring(L: LuaState, s: string | number | null): void { + public lua_pushstring(L: LuaAddress, s: string | number | null): void { if (s === null || s === undefined) { this.lua_pushnil(L) } else if (typeof s === 'number') { @@ -530,7 +518,7 @@ export default class LuaModule { const pointer = this.acquireStringBuffer(capacity) try { // encodeInto avoids materialising an intermediate Uint8Array for the whole string. - const { written } = this.textEncoder.encodeInto(s, this.heap.subarray(pointer, pointer + capacity)) + const { written } = textEncoder.encodeInto(s, this.heap.subarray(pointer, pointer + capacity)) this.lua_pushlstring(L, pointer, written) } finally { this.releaseStringBuffer(pointer) @@ -538,7 +526,7 @@ export default class LuaModule { } } - public lua_pushbytes(L: LuaState, bytes: Uint8Array): void { + public lua_pushbytes(L: LuaAddress, bytes: Uint8Array): void { const pointer = this.acquireStringBuffer(bytes.length) try { this.heap.set(bytes, pointer) @@ -552,29 +540,29 @@ export default class LuaModule { if (!length) { return '' } - return this.textDecoder.decode(this.heap.subarray(pointer, pointer + length)) + return textDecoder.decode(this.heap.subarray(pointer, pointer + length)) } - private toLString(raw: (L: LuaState, idx: number, len: number) => number, L: LuaState, idx: number, len: number | null): string { + private toLString(raw: (L: LuaAddress, idx: number, len: number) => number, L: LuaAddress, idx: number, len: number | null): string { const lengthPointer = len ?? this.sizeScratch const pointer = raw(L, idx, lengthPointer) return pointer ? this.readString(pointer, this.readSize(lengthPointer)) : '' } - public lua_remove(luaState: LuaState, index: number): void { + public lua_remove(luaState: LuaAddress, index: number): void { this.lua_rotate(luaState, index, -1) this.lua_pop(luaState, 1) } - public lua_pop(luaState: LuaState, count: number): void { + public lua_pop(luaState: LuaAddress, count: number): void { this.lua_settop(luaState, -count - 1) } - public luaL_getmetatable(luaState: LuaState, name: string): LuaType { + public luaL_getmetatable(luaState: LuaAddress, name: string): LuaType { return this.lua_getfield(luaState, LUA_REGISTRYINDEX, name) } - public lua_yield(luaState: LuaState, count: number): number { + public lua_yield(luaState: LuaAddress, count: number): number { return this.lua_yieldk(luaState, count, 0, null) } @@ -632,9 +620,9 @@ export default class LuaModule { return this.lastRefIndex } - public printRefs(): void { + public printRefs(log = console.log): void { for (const [key, value] of this.referenceMap.entries()) { - console.log(key, value) + log(key, value) } } @@ -717,21 +705,97 @@ export default class LuaModule { } } +/** + * The callback is asked for more input whenever the C side runs out, and each call satisfies a + * single read: once the returned text is consumed the read ends, so the next one asks again + * instead of blocking for a whole buffer worth of input. An empty string (or nothing at all) + * signals EOF. + */ +function createInputReader(reader?: () => string | null | undefined): (() => number | null) | null { + if (!reader) { + return null + } + + let pending: Uint8Array | undefined + let offset = 0 + + return (): number | null => { + if (pending === undefined) { + const input = reader() ?? '' + if (typeof input !== 'string') { + throw new Error('stdin must return a string') + } + // Not NUL terminated, so a NUL inside the input stays a regular byte. + pending = textEncoder.encode(input) + offset = 0 + } + + if (offset >= pending.length) { + // Ends the current read instead of asking for more, and makes the next one start over. + pending = undefined + return null + } + + return pending[offset++] + } +} + +// Emscripten hands output over one byte at a time, so it has to be reassembled before it can be +// decoded. Doubles from here as lines get longer. +const INITIAL_OUTPUT_CAPACITY = 256 + function createOutputWriter(writer?: (content: string) => void): ((charCode: number | null) => void) | null { if (!writer) { return null } - let buffer = '' + // Its own decoder, because a flush in the middle of a line can cut a multi byte character in + // half and the rest of it only arrives later. + const decoder = new TextDecoder() + let buffer = new Uint8Array(INITIAL_OUTPUT_CAPACITY) + let length = 0 + let flushScheduled = false + + const emit = (endOfLine: boolean): void => { + // A CR right before the line break is part of the terminator, but one anywhere else is + // content and has to survive. + if (endOfLine && length > 0 && buffer[length - 1] === 13) { + length-- + } + // Only the end of a line is a character boundary for sure, so anything else leaves a + // dangling character in the decoder to be completed by the next flush. + const content = decoder.decode(buffer.subarray(0, length), { stream: !endOfLine }) + length = 0 + // An empty line is worth reporting, an incomplete character is not. + if (endOfLine || content.length > 0) { + writer(content) + } + } + return (charCode: number | null): void => { if (charCode === null || charCode === 10) { - writer(buffer) - buffer = '' + emit(true) return } - if (charCode !== 13) { - buffer += String.fromCharCode(charCode) + if (length === buffer.length) { + const grown = new Uint8Array(buffer.length * 2) + grown.set(buffer) + buffer = grown + } + buffer[length++] = charCode + + if (!flushScheduled) { + flushScheduled = true + // Output that never ends in a newline (`io.write` without one, a prompt, ...) would + // otherwise sit here forever. Flushing on a microtask keeps it in the same line as + // whatever else the current run writes, while still handing it over once Lua is done. + queueMicrotask(() => { + flushScheduled = false + if (length > 0) { + emit(false) + } + }) } } } diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100755 index 0000000..c012de8 --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,70 @@ +import LuaModule from './module' +import LuaState from './state' +import { CreateStateOptions } from './types' + +/** + * One loaded Lua wasm module, and the factory for the states that run on it. + * + * States created from the same runtime share the filesystem and the module level stdio, and are + * otherwise independent. + */ +export default class LuaRuntime { + /** + * Loads the Lua wasm module. + * @param opts.wasmFile - Custom URI for the Lua WebAssembly module. + * @param opts.env - Environment variables for the Lua states. + * @param opts.stdin - Standard input, shared by every state on this runtime. + * @param opts.fs - File system that should be used. + * @param opts.stdout - Standard output, shared by every state on this runtime. + * @param opts.stderr - Standard error, shared by every state on this runtime. + * @param opts.onWarn - Where load time diagnostics go. Defaults to `console.warn`. + */ + public static async load(luaModuleOpts: Parameters[0] = {}): Promise { + return new LuaRuntime(await LuaModule.initialize(luaModuleOpts)) + } + + private readonly states = new Set() + + public constructor(public readonly module: LuaModule) {} + + public createState(stateOpts: CreateStateOptions = {}): LuaState { + const state = new LuaState(this.module, stateOpts) + this.states.add(state) + state.onClose(() => this.states.delete(state)) + return state + } + + /** + * Closes every state created from this runtime. The wasm module itself cannot be unloaded, so + * this frees the states rather than the runtime. + */ + public close(): void { + for (const state of this.states) { + state.close() + } + this.states.clear() + } + + public async [Symbol.asyncDispose](): Promise { + this.close() + } + + /** + * Mounts a file in the Lua environment synchronously. + * @param path - Path to the file in the Lua environment. + * @param content - Content of the file to be mounted. + */ + public mountFile(path: string, content: string | ArrayBufferView): void { + const dirname = this.module._emscripten.PATH.dirname(path) + this.module._emscripten.FS.mkdirTree(dirname) + this.module._emscripten.FS.writeFile(path, content) + } + + public get filesystem(): typeof this.module._emscripten.FS { + return this.module._emscripten.FS + } + + public get path(): typeof this.module._emscripten.PATH { + return this.module._emscripten.PATH + } +} diff --git a/src/state.ts b/src/state.ts new file mode 100755 index 0000000..0bcca4a --- /dev/null +++ b/src/state.ts @@ -0,0 +1,296 @@ +import type LuaModule from './module' +import Thread from './thread' +import LuaTypeExtension from './type-extension' +import createErrorType from './type-extensions/error' +import createFunctionType from './type-extensions/function' +import createNullType from './type-extensions/null' +import createPromiseType from './type-extensions/promise' +import createProxyType from './type-extensions/proxy' +import createTableType from './type-extensions/table' +import createUserdataType from './type-extensions/userdata' +import { + CreateStateOptions, + LUA_REGISTRYINDEX, + LuaAddress, + LuaDoOptions, + LuaMemoryOptions, + LuaRunOptions, + LuaType, + resolveLibraryMask, +} from './types' + +/** + * Memory accounting for a state created with `{ memory: { trace: true } }`. On a state without + * tracing `state.memory` is undefined, so the absence is a type error rather than a runtime one. + */ +export interface LuaMemory { + /** Bytes currently allocated by this state. */ + readonly used: number + /** Allocation ceiling in bytes, or undefined for unlimited. */ + max: number | undefined +} + +interface CreatedState { + address: LuaAddress + memory?: LuaMemory + allocatorFunctionPointer?: number +} + +/** + * Tracing needs its own allocator, which has to exist before `lua_newstate`, so this runs ahead + * of the constructor body rather than branching around `super()` twice. + */ +function createAddress(cmodule: LuaModule, memory: LuaMemoryOptions | undefined): CreatedState { + if (!memory?.trace) { + if (memory?.max !== undefined) { + throw new Error('memory.max requires memory.trace to be enabled') + } + return { address: cmodule.luaL_newstate() } + } + + const stats = { used: 0, max: memory.max } + const allocatorFunctionPointer = cmodule._emscripten.addFunction( + (_userData: number, pointer: number, oldSize: number, newSize: number): number => { + if (newSize === 0) { + if (pointer) { + stats.used -= oldSize + cmodule._emscripten._free(pointer) + } + return 0 + } + + const endMemoryDelta = pointer ? newSize - oldSize : newSize + const endMemory = stats.used + endMemoryDelta + + if (newSize > oldSize && stats.max && endMemory > stats.max) { + return 0 + } + + const reallocated = cmodule._emscripten._realloc(pointer, newSize) + if (reallocated) { + stats.used = endMemory + } + return reallocated + }, + 'iiiii', + ) + + const address = cmodule.lua_newstate( + allocatorFunctionPointer, + null, + ((Date.now() >>> 0) ^ Math.floor(Math.random() * 0x100000000)) >>> 0, + ) + if (!address) { + cmodule._emscripten.removeFunction(allocatorFunctionPointer) + throw new Error('lua_newstate returned a null pointer') + } + + return { address, memory: stats, allocatorFunctionPointer } +} + +/** + * An independent Lua state: its globals, its standard libraries and its main thread. + * + * Created through `runtime.createState()`. Several states can share one runtime, and closing one + * does not affect the others. + */ +export default class LuaState extends Thread { + /** Present only when the state was created with `{ memory: { trace: true } }`. */ + public readonly memory: LuaMemory | undefined + + private readonly allocatorFunctionPointer: number | undefined + private readonly defaultMaxInstructions: number | undefined + private readonly closeListeners: (() => void)[] = [] + + public constructor(cmodule: LuaModule, options: CreateStateOptions = {}) { + const { libs = true, objects = 'proxy', errors = objects === 'copy', inject = false, memory, limits, onWarn } = options + + const created = createAddress(cmodule, memory) + super(cmodule, [], created.address) + + this.memory = created.memory + this.allocatorFunctionPointer = created.allocatorFunctionPointer + this.onWarn = onWarn + + if (this.isClosed()) { + throw new Error('Lua state could not be created (probably due to lack of memory)') + } + + // Generic handlers - These may be required to be registered for additional types. + this.registerTypeExtension(0, createTableType(this)) + this.registerTypeExtension(0, createFunctionType(this, { functionTimeout: limits?.functionTimeout })) + + // Contains the :await functionality. + this.registerTypeExtension(1, createPromiseType(this, inject)) + + if (inject) { + // Should be higher priority than table since that catches generic objects along + // with userdata so it doesn't end up a userdata type. + this.registerTypeExtension(5, createNullType(this)) + } + + if (errors) { + this.registerTypeExtension(1, createErrorType(this, inject)) + } + + if (objects === 'proxy') { + // This extension only really overrides tables and arrays. + // When a function is looked up in one of it's tables it's bound and then + // handled by the function type extension. + this.registerTypeExtension(3, createProxyType(this)) + } + + // Higher priority than proxied objects to allow custom user data without exposing methods. + this.registerTypeExtension(4, createUserdataType(this)) + + const libraryMask = resolveLibraryMask(libs) + if (libraryMask !== 0) { + this.lua.luaL_openselectedlibs(this.address, libraryMask, 0) + } + + this.defaultMaxInstructions = limits?.maxInstructions + } + + /** + * Executes Lua code from a string asynchronously. + * @returns A Promise that resolves to the result returned by the Lua script execution. + */ + public doString(script: string, options?: LuaDoOptions): Promise { + return this.callByteCode((thread) => thread.loadString(script, options), this.runOptions(options)) + } + + /** + * Executes Lua code from a file asynchronously. + * @returns A Promise that resolves to the result returned by the Lua script execution. + */ + public doFile(filename: string, options?: LuaDoOptions): Promise { + return this.callByteCode((thread) => thread.loadFile(filename, options), this.runOptions(options)) + } + + /** + * Executes Lua code from a string synchronously. The script cannot yield, so `:await()` and a + * top level `coroutine.yield` are errors here. + */ + public doStringSync(script: string, options?: LuaDoOptions): any { + return this.callByteCodeSync((thread) => thread.loadString(script, options), this.runOptions(options)) + } + + /** + * Executes Lua code from a file synchronously. The script cannot yield, so `:await()` and a + * top level `coroutine.yield` are errors here. + */ + public doFileSync(filename: string, options?: LuaDoOptions): any { + return this.callByteCodeSync((thread) => thread.loadFile(filename, options), this.runOptions(options)) + } + + /** + * Registers a type extension for Lua objects. + * Higher priority is more important and will be evaluated first. + * Allows library users to specify custom types + */ + public registerTypeExtension(priority: number, extension: LuaTypeExtension): void { + this.typeExtensions.push({ extension, priority }) + this.typeExtensions.sort((a, b) => b.priority - a.priority) + } + + /** Retrieves the value of a global variable. */ + public get(name: string): any { + const type = this.lua.lua_getglobal(this.address, name) + const value = this.getValue(-1, type) + this.pop() + return value + } + + /** Sets the value of a global variable. */ + public set(name: string, value: unknown): void { + this.pushValue(value) + this.lua.lua_setglobal(this.address, name) + } + + public getTable(name: string, callback: (index: number) => void): void { + const startStackTop = this.getTop() + const type = this.lua.lua_getglobal(this.address, name) + try { + if (type !== LuaType.Table) { + throw new TypeError(`Unexpected type in ${name}. Expected ${LuaType[LuaType.Table]}. Got ${LuaType[type]}.`) + } + callback(startStackTop + 1) + } finally { + // +1 for the table + if (this.getTop() !== startStackTop + 1) { + this.warn(`getTable: expected stack size ${startStackTop + 1} got ${this.getTop()}`) + } + this.setTop(startStackTop) + } + } + + /** Notified once when this state closes, so an owner can drop its reference. */ + public onClose(listener: () => void): void { + this.closeListeners.push(listener) + } + + /** Closes the state and frees everything it owns. Safe to call more than once. */ + public override close(): void { + if (this.isClosed()) { + return + } + + super.close() + + // Do this before removing the gc to force. + // Here rather than in the threads because you don't + // actually close threads, just pop them. Only the top-level + // lua state needs closing. + this.lua.lua_close(this.address) + + if (this.allocatorFunctionPointer) { + this.lua._emscripten.removeFunction(this.allocatorFunctionPointer) + } + + for (const wrapper of this.typeExtensions) { + wrapper.extension.close() + } + + for (const listener of this.closeListeners) { + listener() + } + this.closeListeners.length = 0 + } + + /** Folds the state wide budget in, so run() does the save and restore in one place. */ + private runOptions(options?: LuaDoOptions): LuaRunOptions | undefined { + if (this.defaultMaxInstructions === undefined) { + return options + } + return { maxInstructions: this.defaultMaxInstructions, ...options } + } + + private callByteCodeSync(loader: (thread: Thread) => void, options?: LuaRunOptions): any { + // Runs on the main thread so the script sees itself as the main coroutine, the way the + // reference implementation behaves. That makes leftovers outlive the call, hence the rewind. + const startStackTop = this.getTop() + try { + loader(this) + return this.runSync(0, options)[0] + } finally { + this.setTop(startStackTop) + } + } + + // WARNING: It will not wait for open handles and can potentially cause bugs if JS code tries to reference Lua after executed + private async callByteCode(loader: (thread: Thread) => void, options?: LuaRunOptions): Promise { + const thread = this.newThread() + // Move the thread off the global stack and into the registry as a GC anchor so it doesn't pile threads up into the stack + const ref = this.lua.luaL_ref(this.address, LUA_REGISTRYINDEX) + try { + // Seeded from the state so a state wide setLimits reaches the async path too, which + // runs on a child thread rather than on the state itself. + thread.setLimits(this.getLimits()) + loader(thread) + return (await thread.run(0, options))[0] + } finally { + thread.close() + this.lua.luaL_unref(this.address, LUA_REGISTRYINDEX, ref) + } + } +} diff --git a/src/thread.ts b/src/thread.ts index 274f570..be92daf 100755 --- a/src/thread.ts +++ b/src/thread.ts @@ -4,14 +4,22 @@ import MultiReturn from './multireturn' import { Pointer } from './pointer' import LuaTypeExtension from './type-extension' import { + defaultWarnHandler, LUA_MULTRET, + LuaAbortError, + LuaAddress, + LuaError, LuaEventMasks, + LuaInstructionLimitError, + LuaInterruptError, + LuaLoadOptions, LuaResumeResult, LuaReturn, - LuaState, - LuaThreadRunOptions, + LuaRunOptions, + LuaThreadLimits, LuaTimeoutError, LuaType, + LuaWarnHandler, PointerSize, } from './types' import { isEmscriptenUnwind, isPromise, yieldToEventLoop } from './utils' @@ -27,14 +35,20 @@ const INSTRUCTION_HOOK_COUNT = 1000 const LUA_INTEGER_BITS = 64 +const NO_RESTORE = (): void => undefined + export default class Thread { - public readonly address: LuaState + public readonly address: LuaAddress public readonly lua: LuaModule + /** Set on the root state; threads created from it delegate here. */ + public onWarn: LuaWarnHandler | undefined protected readonly typeExtensions: OrderedExtension[] + protected readonly parent?: Thread private closed = false private hookFunctionPointer: number | undefined - private timeout?: number - private readonly parent?: Thread + private hookCount = INSTRUCTION_HOOK_COUNT + private limits: LuaThreadLimits = {} + private instructionsUsed = 0 public constructor(lua: LuaModule, typeExtensions: OrderedExtension[], address: number, parent?: Thread) { this.lua = lua @@ -55,20 +69,24 @@ export default class Thread { this.assertOk(this.lua.lua_resetthread(this.address)) } - public loadString(luaCode: string, name?: string): void { + /** @param options.mode defaults to `'t'`. See {@link LuaLoadOptions.mode}. */ + public loadString(luaCode: string, options?: LuaLoadOptions): void { const size = this.lua._emscripten.lengthBytesUTF8(luaCode) const pointerSize = size + 1 const bufferPointer = this.lua._emscripten._malloc(pointerSize) try { this.lua._emscripten.stringToUTF8(luaCode, bufferPointer, pointerSize) - this.assertOk(this.lua.luaL_loadbufferx(this.address, bufferPointer, size, name ?? bufferPointer, null)) + this.assertOk( + this.lua.luaL_loadbufferx(this.address, bufferPointer, size, options?.name ?? bufferPointer, options?.mode ?? 't'), + ) } finally { this.lua._emscripten._free(bufferPointer) } } - public loadFile(filename: string): void { - this.assertOk(this.lua.luaL_loadfilex(this.address, filename, null)) + /** @param options.mode defaults to `'t'`. See {@link LuaLoadOptions.mode}. */ + public loadFile(filename: string, options?: LuaLoadOptions): void { + this.assertOk(this.lua.luaL_loadfilex(this.address, filename, options?.mode ?? 't')) } public resume(argCount = 0): LuaResumeResult { @@ -103,21 +121,19 @@ export default class Thread { this.lua.lua_setfield(this.address, index, name) } - public async run(argCount = 0, options?: Partial): Promise { - const originalTimeout = this.timeout + public async run(argCount = 0, options?: LuaRunOptions): Promise { + const restore = this.applyRunOptions(options) try { - if (options?.timeout !== undefined) { - this.setTimeout(Date.now() + options.timeout) - } let resumeResult: LuaResumeResult = this.resume(argCount) while (resumeResult.result === LuaReturn.Yield) { - // If it's yielded check the timeout. If it's completed no need to - // needlessly discard the output. - if (this.timeout && Date.now() > this.timeout) { + // If it's completed there's no need to needlessly discard the output. The hook + // only fires while Lua runs, so a parked thread is checked here instead. + const limitError = this.checkYieldLimits() + if (limitError) { if (resumeResult.resultCount > 0) { this.pop(resumeResult.resultCount) } - throw new LuaTimeoutError(`thread timeout exceeded`) + throw limitError } if (resumeResult.resultCount > 0) { const lastValue = this.getValue(-1) @@ -135,22 +151,32 @@ export default class Thread { await yieldToEventLoop() } + // The wait itself can outlast the deadline, and resuming would hand Lua another + // full slice before the hook noticed. + const waitError = this.checkYieldLimits() + if (waitError) { + throw waitError + } + resumeResult = this.resume(0) } this.assertOk(resumeResult.result) return this.getStackValues() } finally { - if (options?.timeout !== undefined) { - this.setTimeout(originalTimeout) - } + restore() } } - public runSync(argCount = 0): MultiReturn { - const base = this.getTop() - argCount - 1 // The 1 is for the function to run - this.assertOk(this.lua.lua_pcallk(this.address, argCount, LUA_MULTRET, 0, 0, null) as LuaReturn) - return this.getStackValues(base) + public runSync(argCount = 0, options?: LuaRunOptions): MultiReturn { + const restore = this.applyRunOptions(options) + try { + const base = this.getTop() - argCount - 1 // The 1 is for the function to run + this.assertOk(this.lua.lua_pcallk(this.address, argCount, LUA_MULTRET, 0, 0, null) as LuaReturn) + return this.getStackValues(base) + } finally { + restore() + } } public pop(count = 1): void { @@ -183,7 +209,7 @@ export default class Thread { return returnValues } - public stateToThread(L: LuaState): Thread { + public stateToThread(L: LuaAddress): Thread { return L === this.parent?.address ? this.parent : new Thread(this.lua, this.typeExtensions, L, this.parent || this) } @@ -317,9 +343,14 @@ export default class Thread { return typeExtensionWrapper.extension.getValue(this, index, userdata) } - // Fallthrough if unrecognised user data - console.warn(`The type '${this.lua.lua_typename(this.address, type)}' returned is not supported on JS`) - return new Pointer(this.lua.lua_topointer(this.address, index)) + // Handing back an opaque Pointer hid the failure until the value was used, and + // it could not be pushed back into Lua anyway. + const typeName = this.lua.lua_typename(this.address, type) + const withMetatable = metatableName ? ` with metatable '${metatableName}'` : '' + throw new TypeError( + `the Lua type '${typeName}'${withMetatable} has no JS representation; register a type ` + + `extension to handle it, or read the address with getPointer`, + ) } } } @@ -331,35 +362,44 @@ export default class Thread { if (this.hookFunctionPointer) { this.lua._emscripten.removeFunction(this.hookFunctionPointer) + this.hookFunctionPointer = undefined } this.closed = true } - // Set to > 0 to enable, otherwise disable. - public setTimeout(timeout: number | undefined): void { - if (timeout && timeout > 0) { - if (!this.hookFunctionPointer) { - this.hookFunctionPointer = this.lua._emscripten.addFunction((): void => { - // Reads this.timeout rather than closing over the argument, so a hook - // allocated for an earlier deadline still honours the current one. - if (this.timeout !== undefined && Date.now() > this.timeout) { - this.pushValue(new LuaTimeoutError(`thread timeout exceeded`)) - this.lua.lua_error(this.address) - } - }, 'vii') - } + public [Symbol.dispose](): void { + this.close() + } - this.timeout = timeout - this.lua.lua_sethook(this.address, this.hookFunctionPointer, LuaEventMasks.Count, INSTRUCTION_HOOK_COUNT) - } else { - this.timeout = undefined - this.lua.lua_sethook(this.address, null, 0, 0) - } + /** + * Installs the deadline, instruction budget and abort signal enforced while this thread runs. + * They share a single debug hook, because Lua only allows one hook per thread. + */ + public setLimits(limits: LuaThreadLimits | undefined): void { + this.limits = { ...limits } + this.instructionsUsed = 0 + this.applyHook() + } + + public getLimits(): LuaThreadLimits { + return { ...this.limits } + } + + /** Set to > 0 to enable, otherwise disable. Shorthand for the deadline in {@link setLimits}. */ + public setTimeout(timeout: number | undefined): void { + this.limits.deadline = timeout && timeout > 0 ? timeout : undefined + this.applyHook() } public getTimeout(): number | undefined { - return this.timeout + return this.limits.deadline + } + + /** Diagnostics the library would otherwise have written straight to the console. */ + public warn(message: string, cause?: unknown): void { + const root = this.parent ?? this + ;(root.onWarn ?? defaultWarnHandler)(message, cause) } public getPointer(index: number): Pointer { @@ -399,51 +439,139 @@ export default class Thread { const typename = this.lua.lua_typename(this.address, type) const pointer = this.getPointer(i) const name = this.indexToString(i) - const value = this.getValue(i, type) + let value: unknown + try { + value = this.getValue(i, type) + } catch (err) { + // A debugging aid should survive one unrepresentable slot. + value = `<${(err as Error).message}>` + } log(i, typename, pointer, name, value) } } public assertOk(result: LuaReturn): void { - if (result !== LuaReturn.Ok && result !== LuaReturn.Yield) { - const resultString = LuaReturn[result] - // This is the default message if there's nothing on the stack. - const error = new Error(`Lua Error(${resultString}/${result})`) - if (this.getTop() > 0) { - if (result === LuaReturn.ErrorMem) { - // If there's no memory just do a normal to string. - error.message = this.lua.lua_tolstring(this.address, -1, null) - } else { - const luaError = this.getValue(-1) - if (luaError instanceof Error) { - error.stack = luaError.stack - } + if (result === LuaReturn.Ok || result === LuaReturn.Yield) { + return + } + + // This is the default message if there's nothing on the stack. + let luaMessage = `Lua Error(${LuaReturn[result]}/${result})` + let luaValue: unknown - // Calls __tostring if it exists and pushes onto the stack. - error.message = this.indexToString(-1) + if (this.getTop() > 0) { + if (result === LuaReturn.ErrorMem) { + // If there's no memory just do a normal to string. + luaMessage = this.lua.lua_tolstring(this.address, -1, null) + } else { + try { + luaValue = this.getValue(-1) + } catch { + // An unrepresentable error value must not replace the error being reported. + luaValue = undefined } + + // Calls __tostring if it exists and pushes onto the stack. + luaMessage = this.indexToString(-1) } + } - // Also attempt to get a traceback - if (result !== LuaReturn.ErrorMem) { - try { - this.lua.luaL_traceback(this.address, this.address, null, 1) - const traceback = this.lua.lua_tolstring(this.address, -1, null) - if (traceback.trim() !== 'stack traceback:') { - error.message = `${error.message}\n${traceback}` - } - this.pop(1) // pop stack trace. - } catch (err) { - if (isEmscriptenUnwind(err)) { - throw err - } - console.warn('Failed to generate stack trace', err) + if (luaValue instanceof LuaInterruptError) { + throw luaValue + } + + let traceback: string | undefined + if (result !== LuaReturn.ErrorMem) { + try { + this.lua.luaL_traceback(this.address, this.address, null, 1) + const text = this.lua.lua_tolstring(this.address, -1, null) + if (text.trim() !== 'stack traceback:') { + traceback = text + } + this.pop(1) // pop stack trace. + } catch (err) { + if (isEmscriptenUnwind(err)) { + throw err + } + this.warn('Failed to generate stack trace', err) + } + } + + throw new LuaError(result, luaMessage, { traceback, luaValue }) + } + + private applyRunOptions(options: LuaRunOptions | undefined): () => void { + const overrides = + options !== undefined && + (options.timeout !== undefined || options.maxInstructions !== undefined || options.signal !== undefined) + + if (!overrides) { + return NO_RESTORE + } + + const previousLimits = this.limits + const previousUsed = this.instructionsUsed + + this.setLimits({ + deadline: options.timeout !== undefined ? Date.now() + options.timeout : previousLimits.deadline, + maxInstructions: options.maxInstructions ?? previousLimits.maxInstructions, + signal: options.signal ?? previousLimits.signal, + }) + + return () => { + this.limits = previousLimits + this.instructionsUsed = previousUsed + this.applyHook() + } + } + + private applyHook(): void { + const { deadline, maxInstructions, signal } = this.limits + if (deadline === undefined && maxInstructions === undefined && signal === undefined) { + this.lua.lua_sethook(this.address, null, 0, 0) + return + } + + // A budget smaller than the default period would only ever be noticed late. + this.hookCount = + maxInstructions !== undefined ? Math.max(1, Math.min(INSTRUCTION_HOOK_COUNT, maxInstructions)) : INSTRUCTION_HOOK_COUNT + + if (!this.hookFunctionPointer) { + this.hookFunctionPointer = this.lua._emscripten.addFunction((): void => { + // Reads this.limits rather than closing over them, so a hook allocated for an + // earlier configuration still honours the current one. + const error = this.checkHookLimits() + if (error) { + this.pushValue(error) + this.lua.lua_error(this.address) } + }, 'vii') + } + + this.lua.lua_sethook(this.address, this.hookFunctionPointer, LuaEventMasks.Count, this.hookCount) + } + + private checkHookLimits(): Error | undefined { + const { maxInstructions } = this.limits + if (maxInstructions !== undefined) { + this.instructionsUsed += this.hookCount + if (this.instructionsUsed > maxInstructions) { + return new LuaInstructionLimitError(`thread exceeded its budget of ${maxInstructions} instructions`) } + } + return this.checkYieldLimits() + } - throw error + private checkYieldLimits(): Error | undefined { + const { deadline, signal } = this.limits + if (signal?.aborted) { + return new LuaAbortError('thread aborted') + } + if (deadline !== undefined && Date.now() > deadline) { + return new LuaTimeoutError('thread timeout exceeded') } + return undefined } private getValueDecorations(value: any): Decoration { diff --git a/src/type-extension.ts b/src/type-extension.ts index e4929cc..3bac4ae 100644 --- a/src/type-extension.ts +++ b/src/type-extension.ts @@ -1,14 +1,14 @@ -import { BaseDecorationOptions, Decoration } from './decoration' -import Global from './global' +import { Decoration } from './decoration' +import type LuaState from './state' import Thread from './thread' import { LuaType, PointerSize } from './types' -export default abstract class LuaTypeExtension { +export default abstract class LuaTypeExtension { // Type name, for metatables and lookups. public readonly name: string - protected thread: Global + protected thread: LuaState - public constructor(thread: Global, name: string) { + public constructor(thread: LuaState, name: string) { this.thread = thread this.name = name } @@ -31,7 +31,7 @@ export default abstract class LuaTypeExtension, _userdata?: unknown): boolean { + public pushValue(thread: Thread, decoratedValue: Decoration, _userdata?: unknown): boolean { const { target } = decoratedValue const pointer = thread.lua.ref(target) diff --git a/src/type-extensions/error.ts b/src/type-extensions/error.ts index 745fe6c..a86f4dc 100644 --- a/src/type-extensions/error.ts +++ b/src/type-extensions/error.ts @@ -1,16 +1,16 @@ import { Decoration } from '../decoration' -import Global from '../global' +import type LuaState from '../state' import Thread from '../thread' import TypeExtension from '../type-extension' -import { LuaReturn, LuaState } from '../types' +import { LuaReturn, LuaAddress } from '../types' class ErrorTypeExtension extends TypeExtension { private gcPointer: number - public constructor(thread: Global, injectObject: boolean) { + public constructor(thread: LuaState, injectObject: boolean) { super(thread, 'js_error') - this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaState) => { + this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaAddress) => { // Throws a lua error which does a jump if it does not match. const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') @@ -76,6 +76,6 @@ class ErrorTypeExtension extends TypeExtension { } } -export default function createTypeExtension(thread: Global, injectObject: boolean): TypeExtension { +export default function createTypeExtension(thread: LuaState, injectObject: boolean): TypeExtension { return new ErrorTypeExtension(thread, injectObject) } diff --git a/src/type-extensions/function.ts b/src/type-extensions/function.ts index db39794..ee2d9c5 100644 --- a/src/type-extensions/function.ts +++ b/src/type-extensions/function.ts @@ -1,29 +1,19 @@ -import { BaseDecorationOptions, Decoration } from '../decoration' -import Global from '../global' +import { Decoration } from '../decoration' +import type LuaState from '../state' import MultiReturn from '../multireturn' import RawResult from '../raw-result' import Thread from '../thread' import TypeExtension from '../type-extension' -import { LUA_REGISTRYINDEX, LuaReturn, LuaState, LuaType, PointerSize } from '../types' +import { LUA_REGISTRYINDEX, LuaReturn, LuaAddress, LuaType, PointerSize } from '../types' import { isEmscriptenUnwind } from '../utils' -export interface FunctionDecoration extends BaseDecorationOptions { - receiveArgsQuantity?: boolean - receiveThread?: boolean - self?: any -} - export type FunctionType = (...args: any[]) => Promise | any -export function decorateFunction(target: FunctionType, options: FunctionDecoration): Decoration { - return new Decoration(target, options) -} - export interface FunctionTypeExtensionOptions { functionTimeout?: number } -class FunctionTypeExtension extends TypeExtension { +class FunctionTypeExtension extends TypeExtension { private readonly functionRegistry = new FinalizationRegistry((func: number) => { if (!this.thread.isClosed()) { this.thread.lua.luaL_unref(this.thread.address, LUA_REGISTRYINDEX, func) @@ -36,7 +26,7 @@ class FunctionTypeExtension extends TypeExtension { + this.gcPointer = thread.lua._emscripten.addFunction((calledL: LuaAddress) => { // Throws a lua error which does a jump if it does not match. const userDataPointer = thread.lua.luaL_checkudata(calledL, 1, this.name) const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') @@ -73,12 +63,12 @@ class FunctionTypeExtension extends TypeExtension { + this.functionWrapper = thread.lua._emscripten.addFunction((calledL: LuaAddress) => { const calledThread = thread.stateToThread(calledL) const refUserdata = thread.lua.luaL_checkudata(calledL, thread.lua.lua_upvalueindex(1), this.name) const refPointer = thread.lua._emscripten.getValue(refUserdata, '*') - const { target, options: decorationOptions } = thread.lua.getRef(refPointer) as Decoration + const { target, options: decorationOptions } = thread.lua.getRef(refPointer) as Decoration const argsQuantity = calledThread.getTop() const args = [] @@ -137,7 +127,7 @@ class FunctionTypeExtension extends TypeExtension): boolean { + public pushValue(thread: Thread, decoration: Decoration): boolean { if (typeof decoration.target !== 'function') { return false } @@ -181,8 +171,9 @@ class FunctionTypeExtension extends TypeExtension { +export default function createTypeExtension(thread: LuaState, options?: FunctionTypeExtensionOptions): TypeExtension { return new FunctionTypeExtension(thread, options) } diff --git a/src/type-extensions/null.ts b/src/type-extensions/null.ts index 5c35c50..b38e310 100644 --- a/src/type-extensions/null.ts +++ b/src/type-extensions/null.ts @@ -1,17 +1,17 @@ import { Decoration } from '../decoration' -import Global from '../global' +import type LuaState from '../state' import Thread from '../thread' import TypeExtension from '../type-extension' -import { LUA_REGISTRYINDEX, LuaReturn, LuaState } from '../types' +import { LUA_REGISTRYINDEX, LuaReturn, LuaAddress } from '../types' class NullTypeExtension extends TypeExtension { private gcPointer: number private nullReference: number - public constructor(thread: Global) { + public constructor(thread: LuaState) { super(thread, 'js_null') - this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaState) => { + this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaAddress) => { // Throws a lua error which does a jump if it does not match. const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') @@ -78,6 +78,6 @@ class NullTypeExtension extends TypeExtension { } } -export default function createTypeExtension(thread: Global): TypeExtension { +export default function createTypeExtension(thread: LuaState): TypeExtension { return new NullTypeExtension(thread) } diff --git a/src/type-extensions/promise.ts b/src/type-extensions/promise.ts index 2130c6b..a10dead 100644 --- a/src/type-extensions/promise.ts +++ b/src/type-extensions/promise.ts @@ -1,20 +1,20 @@ import { Decoration } from '../decoration' -import Global from '../global' +import type LuaState from '../state' import MultiReturn from '../multireturn' import RawResult from '../raw-result' import Thread from '../thread' import TypeExtension from '../type-extension' -import { LuaReturn, LuaState } from '../types' +import { LuaReturn, LuaAddress } from '../types' import { isPromise } from '../utils' -import { decorateFunction } from './function' +import { decorate } from '../decoration' class PromiseTypeExtension extends TypeExtension> { private gcPointer: number - public constructor(thread: Global, injectObject: boolean) { + public constructor(thread: LuaState, injectObject: boolean) { super(thread, 'js_promise') - this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaState) => { + this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaAddress) => { // Throws a lua error which does a jump if it does not match. const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') @@ -45,12 +45,14 @@ class PromiseTypeExtension extends TypeExtension> { next: (self: Promise, ...args: Parameters) => checkSelf(self) && self.then(...args), catch: (self: Promise, ...args: Parameters) => checkSelf(self) && self.catch(...args), finally: (self: Promise, ...args: Parameters) => checkSelf(self) && self.finally(...args), - await: decorateFunction( + await: decorate( (functionThread: Thread, self: Promise) => { checkSelf(self) - if (functionThread.address === thread.address) { - throw new Error('cannot await in the main thread') + // Asking Lua covers every non-resumable context, not just the main + // thread: anything entered through lua_pcall cannot yield either. + if (!thread.lua.lua_isyieldable(functionThread.address)) { + throw new Error('cannot await in a thread that cannot yield, use doString instead of doStringSync') } let promiseResult: { status: 'fulfilled' | 'rejected'; value: any } | undefined = undefined @@ -64,7 +66,7 @@ class PromiseTypeExtension extends TypeExtension> { promiseResult = { status: 'rejected', value: err } }) - const continuance = this.thread.lua._emscripten.addFunction((continuanceState: LuaState) => { + const continuance = this.thread.lua._emscripten.addFunction((continuanceState: LuaAddress) => { // If this yield has been called from within a coroutine and so manually resumed // then there may not yet be any results. In that case yield again. if (!promiseResult) { @@ -140,6 +142,6 @@ class PromiseTypeExtension extends TypeExtension> { } } -export default function createTypeExtension(thread: Global, injectObject: boolean): TypeExtension> { +export default function createTypeExtension(thread: LuaState, injectObject: boolean): TypeExtension> { return new PromiseTypeExtension(thread, injectObject) } diff --git a/src/type-extensions/proxy.ts b/src/type-extensions/proxy.ts index 9d4d76b..f151a0f 100644 --- a/src/type-extensions/proxy.ts +++ b/src/type-extensions/proxy.ts @@ -1,28 +1,18 @@ -import { BaseDecorationOptions, Decoration } from '../decoration' -import Global from '../global' +import { decorate, Decoration } from '../decoration' +import type LuaState from '../state' import MultiReturn from '../multireturn' import Thread from '../thread' import TypeExtension from '../type-extension' -import { LuaReturn, LuaState, LuaType } from '../types' +import { LuaReturn, LuaAddress, LuaType } from '../types' import { isPromise } from '../utils' -import { decorateFunction } from './function' -export interface ProxyDecorationOptions extends BaseDecorationOptions { - // If undefined, will try to figure out if should proxy - proxy?: boolean -} - -export function decorateProxy(target: unknown, options?: ProxyDecorationOptions): Decoration { - return new Decoration(target, options || {}) -} - -class ProxyTypeExtension extends TypeExtension { +class ProxyTypeExtension extends TypeExtension { private readonly gcPointer: number - public constructor(thread: Global) { + public constructor(thread: LuaState) { super(thread, 'js_proxy') - this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaState) => { + this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaAddress) => { // Throws a lua error which does a jump if it does not match. const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') @@ -58,7 +48,7 @@ class ProxyTypeExtension extends TypeExtension { const value = self[key as string | number] if (typeof value === 'function') { - return decorateFunction(value as (...args: any[]) => any, { self }) + return decorate(value as (...args: any[]) => any, { self }) } return value @@ -135,9 +125,9 @@ class ProxyTypeExtension extends TypeExtension { return thread.lua.getRef(referencePointer) } - public pushValue(thread: Thread, decoratedValue: Decoration): boolean { + public pushValue(thread: Thread, decoratedValue: Decoration): boolean { const { target, options } = decoratedValue - if (options.proxy === undefined) { + if (options.as === undefined) { if (target === null || target === undefined) { return false } @@ -154,14 +144,15 @@ class ProxyTypeExtension extends TypeExtension { if (isPromise(target)) { return false } - } else if (options.proxy === false) { + } else if (options.as !== 'proxy') { + // Both 'userdata' and 'value' mean "do not go through the proxy layer". return false } if (options.metatable && !(options.metatable instanceof Decoration)) { // Otherwise the metatable will get converted into a JS ref rather than being set as a standard // table. This forces it to use the standard table type. - decoratedValue.options.metatable = decorateProxy(options.metatable, { proxy: false }) + decoratedValue.options.metatable = decorate(options.metatable, { as: 'value' }) return false } @@ -173,6 +164,6 @@ class ProxyTypeExtension extends TypeExtension { } } -export default function createTypeExtension(thread: Global): TypeExtension { +export default function createTypeExtension(thread: LuaState): TypeExtension { return new ProxyTypeExtension(thread) } diff --git a/src/type-extensions/table.ts b/src/type-extensions/table.ts index 3ca449b..e6e6bbf 100644 --- a/src/type-extensions/table.ts +++ b/src/type-extensions/table.ts @@ -1,5 +1,5 @@ import { Decoration } from '../decoration' -import Global from '../global' +import type LuaState from '../state' import Thread from '../thread' import TypeExtension from '../type-extension' import { LUA_REGISTRYINDEX, LuaType } from '../types' @@ -7,7 +7,7 @@ import { LUA_REGISTRYINDEX, LuaType } from '../types' export type TableType = Record | any[] class TableTypeExtension extends TypeExtension { - public constructor(thread: Global) { + public constructor(thread: LuaState) { super(thread, 'js_table') } @@ -126,6 +126,6 @@ class TableTypeExtension extends TypeExtension { } } -export default function createTypeExtension(thread: Global): TypeExtension { +export default function createTypeExtension(thread: LuaState): TypeExtension { return new TableTypeExtension(thread) } diff --git a/src/type-extensions/userdata.ts b/src/type-extensions/userdata.ts index 89a3811..ac9cd5a 100644 --- a/src/type-extensions/userdata.ts +++ b/src/type-extensions/userdata.ts @@ -1,24 +1,16 @@ -import { BaseDecorationOptions, Decoration } from '../decoration' -import Global from '../global' +import { Decoration } from '../decoration' +import type LuaState from '../state' import Thread from '../thread' import TypeExtension from '../type-extension' -import { LuaReturn, LuaState, LuaType } from '../types' +import { LuaReturn, LuaAddress, LuaType } from '../types' -export interface UserdataDecorationOptions extends BaseDecorationOptions { - reference?: boolean -} - -export function decorateUserdata(target: unknown): Decoration { - return new Decoration(target, { reference: true }) -} - -class UserdataTypeExtension extends TypeExtension { +class UserdataTypeExtension extends TypeExtension { private readonly gcPointer: number - public constructor(thread: Global) { + public constructor(thread: LuaState) { super(thread, 'js_userdata') - this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaState) => { + this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaAddress) => { // Throws a lua error which does a jump if it does not match. const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') @@ -53,8 +45,8 @@ class UserdataTypeExtension extends TypeExtension): boolean { - if (!decoratedValue.options.reference) { + public pushValue(thread: Thread, decoratedValue: Decoration): boolean { + if (decoratedValue.options.as !== 'userdata') { return false } @@ -66,6 +58,6 @@ class UserdataTypeExtension extends TypeExtension { +export default function createTypeExtension(thread: LuaState): TypeExtension { return new UserdataTypeExtension(thread) } diff --git a/src/types.ts b/src/types.ts index 393c812..0985b9c 100755 --- a/src/types.ts +++ b/src/types.ts @@ -1,16 +1,138 @@ -export type LuaState = number - -export interface CreateEngineOptions { - /** Injects all the lua standard libraries (math, coroutine, debug) */ - openStandardLibs?: boolean - /** Injects some JS objects to the Lua environment: Error, Promise, null, Objects */ - injectObjects?: boolean - /** Enables the proxy for JS objects, useful for classes, etc... */ - enableProxy?: boolean - /** Whether to trace memory allocations. */ - traceAllocations?: boolean - /** Maximum time in milliseconds a Lua function can run before being interrupted. */ +/** A pointer to a `lua_State` inside the wasm heap. */ +export type LuaAddress = number + +/** Receives diagnostics the library would otherwise have written to the console. */ +export type LuaWarnHandler = (message: string, cause?: unknown) => void + +export const defaultWarnHandler: LuaWarnHandler = (message, cause) => { + if (cause === undefined) { + console.warn(message) + } else { + console.warn(message, cause) + } +} + +export type LuaLibName = 'base' | 'package' | 'coroutine' | 'debug' | 'io' | 'math' | 'os' | 'string' | 'table' | 'utf8' + +/** + * Mirrors the `LUA_*K` bitmask in `lualib.h`. These are transcribed rather than derived, so + * initialization.test.js opens each library alone and checks the global it installs, to catch a + * reordering on a Lua version bump. + */ +export const LUA_LIB_BITS: Record = { + base: 1, + package: 2, + coroutine: 4, + debug: 8, + io: 16, + math: 32, + os: 64, + string: 128, + table: 256, + utf8: 512, +} + +export const ALL_LUA_LIBS = ~0 + +export function resolveLibraryMask(libs: LuaLibName[] | boolean | undefined): number { + if (libs === undefined || libs === true) { + return ALL_LUA_LIBS + } + if (libs === false) { + return 0 + } + + let mask = 0 + for (const lib of libs) { + const bit = LUA_LIB_BITS[lib] + if (bit === undefined) { + throw new Error(`unknown Lua library: ${String(lib)}`) + } + mask |= bit + } + return mask +} + +/** `'t'` accepts text chunks only, `'bt'` also accepts precompiled bytecode. */ +export type LuaLoadMode = 't' | 'bt' + +export interface LuaMemoryOptions { + /** + * Installs a custom allocator so memory can be measured and capped. Without it `state.memory` + * is undefined. + */ + trace?: boolean + /** Maximum bytes the state may allocate. Requires `trace`. */ + max?: number +} + +export interface LuaLimitOptions { + /** Milliseconds a Lua function called from JS may run before being interrupted. */ functionTimeout?: number + /** Instructions a single run may execute before being interrupted. */ + maxInstructions?: number +} + +export interface CreateStateOptions { + /** + * Which standard libraries to open. `true` opens all of them, `false` opens none (which + * leaves the state without even `tostring`), or name them individually. + */ + libs?: LuaLibName[] | boolean + /** + * How plain JS objects and class instances cross into Lua. `'proxy'` keeps their identity and + * exposes their members, `'copy'` marshals them into plain Lua tables. + */ + objects?: 'proxy' | 'copy' + /** + * Registers the JS `Error` to Lua error bridge. Defaults to true when `objects` is `'copy'`, + * because the proxy already covers errors when it is enabled. + */ + errors?: boolean + /** Injects `Error`, `Promise` and `null` into the Lua globals. */ + inject?: boolean + memory?: LuaMemoryOptions + limits?: LuaLimitOptions + /** Where diagnostics go. Defaults to `console.warn`. */ + onWarn?: LuaWarnHandler +} + +export interface LuaRunOptions { + /** Milliseconds before the run is interrupted. */ + timeout?: number + /** Instructions the run may execute before being interrupted. */ + maxInstructions?: number + /** + * Interrupts the run when the signal aborts. + * + * The abort is observed at the debug hook and around every yield, which has two consequences. + * It cannot fire while the event loop is blocked, so a signal aborted from a timer will not + * interrupt a tight synchronous Lua loop; use `timeout` or `maxInstructions` for those, since + * the hook evaluates them on its own. And a run parked on a promise finishes awaiting that + * promise before the abort is seen, rather than abandoning it mid-flight. + */ + signal?: AbortSignal +} + +export interface LuaLoadOptions { + /** + * Defaults to `'t'`. Lua does not verify the consistency of binary chunks, so accepting + * bytecode from an untrusted source is a memory safety hole rather than a sandbox escape. + * Only widen this for chunks you produced yourself. + */ + mode?: LuaLoadMode + /** Chunk name used in error messages and tracebacks. */ + name?: string +} + +export type LuaDoOptions = LuaRunOptions & LuaLoadOptions + +/** Deadline and budget enforced by the debug hook while a thread runs. */ +export interface LuaThreadLimits { + /** Absolute timestamp, as returned by `Date.now()`. */ + deadline?: number + maxInstructions?: number + signal?: AbortSignal } export enum LuaReturn { @@ -28,10 +150,6 @@ export interface LuaResumeResult { resultCount: number } -export interface LuaThreadRunOptions { - timeout?: number -} - export const PointerSize = 4 export const LUA_MULTRET = -1 @@ -66,17 +184,55 @@ export enum LuaEventMasks { Count = 1 << LuaEventCodes.Count, } -export enum LuaLibraries { - Base = '_G', - Coroutine = 'coroutine', - Table = 'table', - IO = 'io', - OS = 'os', - String = 'string', - UTF8 = 'utf8', - Math = 'math', - Debug = 'debug', - Package = 'package', +/** + * An error raised by Lua itself. The pieces are kept apart rather than flattened into `message` + * so callers can match on `code`, re-raise `luaValue`, or render the traceback separately. + */ +export class LuaError extends Error { + public override readonly name: string = 'LuaError' + /** Which `lua_pcall`/`lua_resume` status produced this. */ + public readonly code: LuaReturn + /** The error text on its own, without the traceback appended. */ + public readonly luaMessage: string + public readonly traceback: string | undefined + /** The value Lua actually raised, which is not always a string. */ + public readonly luaValue: unknown + + public constructor(code: LuaReturn, luaMessage: string, options: { traceback?: string; luaValue?: unknown } = {}) { + super(luaMessage) + this.code = code + this.luaMessage = luaMessage + this.traceback = options.traceback + this.luaValue = options.luaValue + + if (options.luaValue instanceof Error && options.luaValue.stack) { + // A JS error that travelled through Lua keeps the stack from where it was thrown. + this.stack = options.luaValue.stack + } else if (options.traceback) { + // Default logging only prints `stack`, so the traceback still has to be reachable + // there even though it is no longer part of the message. + this.stack = `${this.name}: ${luaMessage}\n${options.traceback}` + } + } } -export class LuaTimeoutError extends Error {} +/** + * Raised by the debug hook to unwind a run that hit a limit. These are control flow rather than + * Lua errors, so they reach the caller unwrapped instead of inside a {@link LuaError}, and a + * single `catch` covers every way a run can be cut short. + */ +export class LuaInterruptError extends Error { + public override readonly name: string = 'LuaInterruptError' +} + +export class LuaTimeoutError extends LuaInterruptError { + public override readonly name: string = 'LuaTimeoutError' +} + +export class LuaInstructionLimitError extends LuaInterruptError { + public override readonly name: string = 'LuaInstructionLimitError' +} + +export class LuaAbortError extends LuaInterruptError { + public override readonly name: string = 'LuaAbortError' +} diff --git a/test/browser.test.js b/test/browser.test.js index 57d94e2..ea7bb3c 100644 --- a/test/browser.test.js +++ b/test/browser.test.js @@ -19,9 +19,9 @@ function startServer() { @@ -92,7 +92,7 @@ describe('Browser environment', () => { it('load Lua engine in browser should succeed', async function () { this.timeout(30_000) const result = await runInBrowser(` - const lua = await Lua.load({ wasmFile }) + const lua = await LuaRuntime.load({ wasmFile }) const state = lua.createState() return state !== undefined `) @@ -102,7 +102,7 @@ describe('Browser environment', () => { it('execute Lua code in browser should succeed', async function () { this.timeout(30_000) const result = await runInBrowser(` - const lua = await Lua.load({ wasmFile }) + const lua = await LuaRuntime.load({ wasmFile }) const state = lua.createState() return await state.doString('return 2 + 2') `) @@ -112,9 +112,9 @@ describe('Browser environment', () => { it('pass JS values to Lua and back in browser should succeed', async function () { this.timeout(30_000) const result = await runInBrowser(` - const lua = await Lua.load({ wasmFile }) - const state = lua.createState({ injectObjects: true }) - state.global.set('name', 'wasmoon') + const lua = await LuaRuntime.load({ wasmFile }) + const state = lua.createState({ inject: true }) + state.set('name', 'wasmoon') return await state.doString('return "hello " .. name') `) expect(result).to.be.equal('hello wasmoon') @@ -123,9 +123,9 @@ describe('Browser environment', () => { it('call JS function from Lua in browser should succeed', async function () { this.timeout(30_000) const result = await runInBrowser(` - const lua = await Lua.load({ wasmFile }) - const state = lua.createState({ injectObjects: true }) - state.global.set('add', (a, b) => a + b) + const lua = await LuaRuntime.load({ wasmFile }) + const state = lua.createState({ inject: true }) + state.set('add', (a, b) => a + b) return await state.doString('return add(10, 20)') `) expect(result).to.be.equal(30) @@ -134,7 +134,7 @@ describe('Browser environment', () => { it('mount and require a file in browser should succeed', async function () { this.timeout(30_000) const result = await runInBrowser(` - const lua = await Lua.load({ wasmFile }) + const lua = await LuaRuntime.load({ wasmFile }) lua.mountFile('mymodule.lua', 'return 42') const state = lua.createState() return await state.doString('return require("mymodule")') @@ -145,8 +145,8 @@ describe('Browser environment', () => { it('handle Lua tables as JS objects in browser should succeed', async function () { this.timeout(30_000) const result = await runInBrowser(` - const lua = await Lua.load({ wasmFile }) - const state = lua.createState({ injectObjects: true }) + const lua = await LuaRuntime.load({ wasmFile }) + const state = lua.createState({ inject: true }) const value = await state.doString('return { x = 10, y = 20 }') return { x: value.x, y: value.y } `) @@ -156,7 +156,7 @@ describe('Browser environment', () => { it('handle errors in browser should succeed', async function () { this.timeout(30_000) const result = await runInBrowser(` - const lua = await Lua.load({ wasmFile }) + const lua = await LuaRuntime.load({ wasmFile }) const state = lua.createState() try { await state.doString('error("test error")') @@ -171,7 +171,7 @@ describe('Browser environment', () => { it('use coroutines in browser should succeed', async function () { this.timeout(30_000) const result = await runInBrowser(` - const lua = await Lua.load({ wasmFile }) + const lua = await LuaRuntime.load({ wasmFile }) const state = lua.createState() return await state.doString(\` local co = coroutine.create(function() @@ -196,7 +196,7 @@ describe('Browser environment', () => { this.timeout(30_000) // Browsers have no setImmediate, which the yield path used to depend on. const result = await runInBrowser(` - const lua = await Lua.load({ wasmFile }) + const lua = await LuaRuntime.load({ wasmFile }) const state = lua.createState() return await state.doString('coroutine.yield() return 7') `) diff --git a/test/debug.js b/test/debug.js index 1373e84..9f6e871 100644 --- a/test/debug.js +++ b/test/debug.js @@ -2,9 +2,9 @@ import { getState } from './utils.js' // This file was created as a sandbox to test and debug on vscode const state = await getState() -state.global.set('potato', { +state.set('potato', { test: true, hello: ['world'], }) -state.global.get('potato') +state.get('potato') state.doStringSync('print(potato.hello[1])') diff --git a/test/errors.test.js b/test/errors.test.js new file mode 100644 index 0000000..2d9c9c6 --- /dev/null +++ b/test/errors.test.js @@ -0,0 +1,70 @@ +import { LuaRuntime, LuaError } from '../dist/index.js' +import { expect } from 'chai' +import { getState } from './utils.js' + +describe('LuaError', () => { + it('keeps the raised value, message and traceback apart', async () => { + const state = await getState() + + try { + await state.doString(`error({ code = 7 })`) + throw new Error('should not be reached') + } catch (err) { + expect(err).to.be.instanceOf(LuaError) + expect(err.code).to.be.equal(2) + expect(err.luaValue).to.be.eql({ code: 7 }) + expect(err.message).to.not.include('stack traceback:') + expect(err.traceback).to.include('stack traceback:') + } + }) + + it('a JS error thrown through Lua keeps its own stack', async () => { + const state = await getState() + const thrown = new Error('from js') + state.set('boom', () => { + throw thrown + }) + + try { + await state.doString('boom()') + throw new Error('should not be reached') + } catch (err) { + expect(err.luaValue).to.be.equal(thrown) + expect(err.stack).to.be.equal(thrown.stack) + } + }) +}) + +describe('Unrepresentable values', () => { + it('getValue throws instead of returning an opaque handle', async () => { + const state = await getState() + const thread = state.newThread() + thread.lua.lua_newuserdatauv(thread.address, 4, 0) + + expect(() => thread.getValue(-1)).to.throw('has no JS representation') + }) + + it('the address is still reachable through getPointer', async () => { + const state = await getState() + const thread = state.newThread() + thread.lua.lua_newuserdatauv(thread.address, 4, 0) + + expect(Number(thread.getPointer(-1))).to.be.greaterThan(0) + }) +}) + +describe('Warnings', () => { + it('onWarn receives diagnostics instead of the console', async () => { + const lua = await LuaRuntime.load() + const seen = [] + using state = lua.createState({ onWarn: (message) => seen.push(message) }) + + state.getTable('_G', () => { + // Leaves the stack unbalanced on purpose. + state.lua.lua_pushnil(state.address) + }) + + expect(seen).to.have.lengthOf(1) + expect(seen[0]).to.include('getTable: expected stack size') + }) +}) diff --git a/test/filesystem.test.js b/test/filesystem.test.js index d1c5f54..09a5f16 100644 --- a/test/filesystem.test.js +++ b/test/filesystem.test.js @@ -9,7 +9,7 @@ describe('Filesystem', () => { await state.doString('require("test")') - expect(state.global.get('answerToLifeTheUniverseAndEverything')).to.be.equal(42) + expect(state.get('answerToLifeTheUniverseAndEverything')).to.be.equal(42) }) it('mount a file in a complex directory and require inside lua should succeed', async () => { diff --git a/test/initialization.test.js b/test/initialization.test.js index 90952b4..ab8f214 100644 --- a/test/initialization.test.js +++ b/test/initialization.test.js @@ -1,27 +1,27 @@ -import { Lua } from '../dist/index.js' +import { LuaRuntime } from '../dist/index.js' import { expect } from 'chai' describe('Initialization', () => { it('create state should succeed', async () => { - const lua = await Lua.load() + const lua = await LuaRuntime.load() lua.createState() }) it('create multiple states should succeed', async () => { - const lua = await Lua.load() + const lua = await LuaRuntime.load() const state1 = lua.createState() const state2 = lua.createState() - expect(state1.global.address).to.not.be.equal(state2.global.address) + expect(state1.address).to.not.be.equal(state2.address) }) it('create state with options should succeed', async () => { - const lua = await Lua.load() + const lua = await LuaRuntime.load() lua.createState({ - enableProxy: true, - injectObjects: true, - openStandardLibs: true, - traceAllocations: true, + objects: 'proxy', + inject: true, + libs: true, + memory: { trace: true }, }) }) @@ -29,7 +29,7 @@ describe('Initialization', () => { const env = { ENV_TEST: 'test', } - const lua = await Lua.load({ env }) + const lua = await LuaRuntime.load({ env }) const state = lua.createState() const value = await state.doString('return os.getenv("ENV_TEST")') @@ -37,3 +37,110 @@ describe('Initialization', () => { expect(value).to.be.equal(env.ENV_TEST) }) }) + +describe('Standard libraries', () => { + // LUA_LIB_BITS is transcribed by hand from lualib.h, so each bit is checked against the + // global its library installs. A reordering on a Lua version bump fails here. + const cases = [ + ['base', 'print'], + ['package', 'require'], + ['coroutine', 'coroutine'], + ['debug', 'debug'], + ['io', 'io'], + ['math', 'math'], + ['os', 'os'], + ['string', 'string'], + ['table', 'table'], + ['utf8', 'utf8'], + ] + + for (const [lib, global] of cases) { + it(`libs: ['${lib}'] installs ${global} and nothing else`, async () => { + const lua = await LuaRuntime.load() + // base comes along so `type` exists; io holds FILE* userdata with no JS + // representation, so presence is asserted from Lua rather than marshalled. + using state = lua.createState({ libs: lib === 'base' ? ['base'] : ['base', lib] }) + const typeOf = (name) => state.doStringSync(`return type(${name})`) + + expect(typeOf(global), `${lib} should install ${global}`).to.not.be.equal('nil') + + for (const [otherLib, otherGlobal] of cases) { + if (otherLib === lib || otherLib === 'base' || otherGlobal === global) { + continue + } + expect(typeOf(otherGlobal), `${lib} should not install ${otherGlobal}`).to.be.equal('nil') + } + }) + } + + it('libs: false leaves the state empty', async () => { + const lua = await LuaRuntime.load() + using state = lua.createState({ libs: false }) + + // Without the base library there is no `type` either, so this reads the globals directly. + expect(state.get('print')).to.be.null + expect(state.get('tostring')).to.be.null + }) + + it('libs defaults to all of them', async () => { + const lua = await LuaRuntime.load() + using state = lua.createState() + + for (const [, global] of cases) { + expect(state.doStringSync(`return type(${global})`), global).to.not.be.equal('nil') + } + }) + + it('an unknown library name is rejected', async () => { + const lua = await LuaRuntime.load() + + expect(() => lua.createState({ libs: ['nope'] })).to.throw('unknown Lua library: nope') + }) +}) + +describe('Disposal', () => { + it('using closes the state at the end of the block', async () => { + const lua = await LuaRuntime.load() + let escaped + { + using state = lua.createState() + escaped = state + expect(state.isClosed()).to.be.false + } + + expect(escaped.isClosed()).to.be.true + }) + + it('closing the runtime closes every state it created', async () => { + const lua = await LuaRuntime.load() + const first = lua.createState() + const second = lua.createState() + + lua.close() + + expect(first.isClosed()).to.be.true + expect(second.isClosed()).to.be.true + }) + + it('a closed state is released by the runtime that made it', async () => { + const lua = await LuaRuntime.load() + + // Without this the Set grows for the runtime's lifetime, which a create/close loop hits. + for (let index = 0; index < 100; index++) { + lua.createState().close() + } + const retained = Object.values(lua).find((value) => value instanceof Set) + + expect(retained.size).to.be.equal(0) + }) + + it('await using disposes the runtime', async () => { + let escaped + { + await using lua = await LuaRuntime.load() + escaped = lua.createState() + } + + expect(escaped.isClosed()).to.be.true + }) +}) diff --git a/test/limits.test.js b/test/limits.test.js new file mode 100644 index 0000000..6a9323f --- /dev/null +++ b/test/limits.test.js @@ -0,0 +1,75 @@ +import { LuaInterruptError, LuaTimeoutError, LuaInstructionLimitError, LuaAbortError } from '../dist/index.js' +import { expect } from 'chai' +import { getState } from './utils.js' + +describe('Run limits', () => { + const busyLoop = 'local x = 0 for i = 1, 1e9 do x = x + 1 end return x' + + it('timeout interrupts a tight loop', async function () { + this.timeout(20_000) + const state = await getState() + + await expect(state.doString(busyLoop, { timeout: 20 })).to.eventually.be.rejectedWith(LuaTimeoutError) + }) + + it('maxInstructions interrupts a tight loop', async function () { + this.timeout(20_000) + const state = await getState() + + await expect(state.doString(busyLoop, { maxInstructions: 5_000 })).to.eventually.be.rejectedWith(LuaInstructionLimitError) + }) + + it('an already aborted signal stops the run', async function () { + this.timeout(20_000) + const state = await getState() + + await expect(state.doString(busyLoop, { signal: AbortSignal.abort() })).to.eventually.be.rejectedWith(LuaAbortError) + }) + + it('a signal aborted while parked on a promise stops the run', async function () { + this.timeout(20_000) + const state = await getState() + state.set('sleep', (ms) => new Promise((resolve) => setTimeout(resolve, ms))) + const controller = new AbortController() + setTimeout(() => controller.abort(), 5) + + await expect(state.doString('sleep(30):await() return 1', { signal: controller.signal })).to.eventually.be.rejectedWith( + LuaAbortError, + ) + }) + + it('maxInstructions from the state options applies to every run', async function () { + this.timeout(20_000) + using state = await getState({ limits: { maxInstructions: 5_000 } }) + + await expect(state.doString(busyLoop)).to.eventually.be.rejectedWith(LuaInstructionLimitError) + expect(await state.doString('return 1 + 1')).to.be.equal(2) + }) + + it('setLimits on the state reaches both the sync and the async path', async function () { + this.timeout(20_000) + using state = await getState() + state.setLimits({ maxInstructions: 5_000 }) + + await expect(state.doString(busyLoop)).to.eventually.be.rejectedWith(LuaInstructionLimitError) + expect(() => state.doStringSync(busyLoop)).to.throw(LuaInstructionLimitError) + }) + + it('every limit shares one catchable base', async function () { + this.timeout(20_000) + using state = await getState() + + for (const options of [{ timeout: 20 }, { maxInstructions: 5_000 }, { signal: AbortSignal.abort() }]) { + await expect(state.doString(busyLoop, options)).to.eventually.be.rejectedWith(LuaInterruptError) + } + }) + + it('limits are restored after a scoped run', async () => { + const state = await getState() + state.setLimits({ maxInstructions: 999 }) + + await state.doString('return 1', { maxInstructions: 10_000 }) + + expect(state.getLimits().maxInstructions).to.be.equal(999) + }) +}) diff --git a/test/luatests.js b/test/luatests.js index 27460f6..e873287 100644 --- a/test/luatests.js +++ b/test/luatests.js @@ -1,8 +1,8 @@ -import { Lua } from '../dist/index.js' +import { LuaRuntime } from '../dist/index.js' import { fileURLToPath } from 'node:url' import { readFile, glob } from 'node:fs/promises' -const lua = await Lua.load() +const lua = await LuaRuntime.load() const testsPath = import.meta.resolve('../lua/testes') const filePath = fileURLToPath(typeof testsPath === 'string' ? testsPath : await Promise.resolve(testsPath)) @@ -33,11 +33,11 @@ for await (const file of glob(`${filePath}/**/*.lua`)) { } const state = lua.createState() -lua.module.lua_warning(state.global.address, '@on', 0) -state.global.set('arg', ['lua', 'all.lua']) -state.global.set('_port', true) -state.global.getTable('os', (i) => { - state.global.setField(i, 'setlocale', (locale) => { +lua.module.lua_warning(state.address, '@on', 0) +state.set('arg', ['lua', 'all.lua']) +state.set('_port', true) +state.getTable('os', (i) => { + state.setField(i, 'setlocale', (locale) => { return locale && locale !== 'C' ? false : 'C' }) }) diff --git a/test/node.test.js b/test/node.test.js index 1fd0ded..c581084 100644 --- a/test/node.test.js +++ b/test/node.test.js @@ -3,25 +3,44 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { spawn } from 'node:child_process' import { expect } from 'chai' -import { Lua } from '../dist/index.js' +import { LuaRuntime } from '../dist/index.js' -// stdin stays closed, as it would be for a redirected or piped invocation. The CLI keeps a -// readline interface open otherwise and never exits. -const runCli = (args) => { +// stdin is closed by default, as it would be for a redirected or piped invocation with nothing +// to read. +const runCli = (args, input) => { return new Promise((resolve, reject) => { - const child = spawn(process.execPath, ['bin/wasmoon', ...args], { stdio: ['ignore', 'pipe', 'pipe'] }) + const child = spawn(process.execPath, ['bin/wasmoon', ...args], { + stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + }) let stdout = '' let stderr = '' child.stdout.on('data', (chunk) => (stdout += chunk)) child.stderr.on('data', (chunk) => (stderr += chunk)) child.on('error', reject) child.on('close', (code) => resolve({ code, stdout, stderr })) + if (input !== undefined) { + child.stdin.end(input) + } }) } +// Collects everything the given script writes to the chosen stream. +const collectOutput = async (stream, script, opts = {}) => { + const output = [] + const lua = await LuaRuntime.load({ ...opts, [stream]: (content) => output.push(content) }) + await lua.createState().doString(script) + return output +} + +// Feeds one chunk per read, and then the empty string that stands for EOF. +const stdinFrom = (chunks) => { + let index = 0 + return () => chunks[index++] ?? '' +} + describe('Node environment', () => { it('load Lua engine in node should succeed', async () => { - const lua = await Lua.load() + const lua = await LuaRuntime.load() const state = lua.createState() const result = await state.doString('return 1 + 1') expect(result).to.be.equal(2) @@ -29,42 +48,32 @@ describe('Node environment', () => { it('access environment variables should succeed', async () => { const env = { MY_TEST_VAR: 'hello_from_node' } - const lua = await Lua.load({ env }) + const lua = await LuaRuntime.load({ env }) const state = lua.createState() const result = await state.doString('return os.getenv("MY_TEST_VAR")') expect(result).to.be.equal('hello_from_node') }) it('custom stdout should succeed', async () => { - const output = [] - const lua = await Lua.load({ - stdout: (content) => output.push(content), - }) - const state = lua.createState() - await state.doString('print("hello from node")') + const output = await collectOutput('stdout', 'print("hello from node")') expect(output.join('')).to.include('hello from node') }) it('custom stderr should succeed', async () => { - const errors = [] - const lua = await Lua.load({ - stderr: (content) => errors.push(content), - }) - const state = lua.createState() - await state.doString('io.stderr:write("error output\\n")') + const errors = await collectOutput('stderr', 'io.stderr:write("error output\\n")') expect(errors.join('')).to.include('error output') }) it('mount file and require in node should succeed', async () => { - const lua = await Lua.load() + const lua = await LuaRuntime.load() lua.mountFile('nodetest.lua', 'return { value = 99 }') - const state = lua.createState({ injectObjects: true }) + const state = lua.createState({ inject: true }) const result = await state.doString('local m = require("nodetest"); return m.value') expect(result).to.be.equal(99) }) it('mount file with binary content should succeed', async () => { - const lua = await Lua.load() + const lua = await LuaRuntime.load() const content = new Uint8Array([114, 101, 116, 117, 114, 110, 32, 52, 50]) // "return 42" lua.mountFile('binary.lua', content) const state = lua.createState() @@ -73,7 +82,7 @@ describe('Node environment', () => { }) it('mount file in nested directories should succeed', async () => { - const lua = await Lua.load() + const lua = await LuaRuntime.load() lua.mountFile('a/b/c/deep.lua', 'return "deep"') const state = lua.createState() const result = await state.doString('return require("a/b/c/deep")') @@ -81,7 +90,7 @@ describe('Node environment', () => { }) it('create multiple independent states should succeed', async () => { - const lua = await Lua.load() + const lua = await LuaRuntime.load() const state1 = lua.createState() const state2 = lua.createState() @@ -96,8 +105,8 @@ describe('Node environment', () => { }) it('pass complex JS objects to Lua should succeed', async () => { - const state = (await Lua.load()).createState({ injectObjects: true }) - state.global.set('data', { + const state = (await LuaRuntime.load()).createState({ inject: true }) + state.set('data', { name: 'test', nested: { value: 42 }, items: [1, 2, 3], @@ -107,8 +116,8 @@ describe('Node environment', () => { }) it('call JS async functions from Lua should succeed', async () => { - const state = (await Lua.load()).createState({ injectObjects: true }) - state.global.set('fetchData', async () => { + const state = (await LuaRuntime.load()).createState({ inject: true }) + state.set('fetchData', async () => { return 'async result' }) const result = await state.doString('return fetchData():await()') @@ -122,7 +131,7 @@ describe('Node environment', () => { writeFileSync(testFile, 'hello from host') try { - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` local f = io.open("${testFile.replace(/\\/g, '/')}", "r") @@ -142,7 +151,7 @@ describe('Node environment', () => { const testFile = join(tempDir, 'output.txt') try { - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() await state.doString(` local f = io.open("${testFile.replace(/\\/g, '/')}", "w") @@ -157,7 +166,7 @@ describe('Node environment', () => { }) it('NODEFS cwd should match process.cwd', async () => { - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() // Write a temp file in the CWD and verify it can be read const markerFile = join(process.cwd(), `.wasmoon-cwd-test-${Date.now()}.tmp`) @@ -183,7 +192,7 @@ describe('Node environment', () => { writeFileSync(testFile, 'mounted content') try { - const lua = await Lua.load({ fs: 'node', fsMountPaths: [tmpdir()] }) + const lua = await LuaRuntime.load({ fs: 'node', fsMountPaths: [tmpdir()] }) const state = lua.createState() const result = await state.doString(` local f = io.open("${testFile.replace(/\\/g, '/')}", "r") @@ -199,7 +208,7 @@ describe('Node environment', () => { }) it('NODEFS opening nonexistent file should return nil', async () => { - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` local f, err = io.open("/tmp/wasmoon_nonexistent_file_${Date.now()}.txt", "r") @@ -215,7 +224,7 @@ describe('Node environment', () => { writeFileSync(hostFile, 'from host') try { - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) lua.mountFile('virtual.lua', 'return "from virtual"') const state = lua.createState() @@ -236,7 +245,7 @@ describe('Node environment', () => { }) it('standard libraries are available should succeed', async () => { - const state = (await Lua.load()).createState() + const state = (await LuaRuntime.load()).createState() const result = await state.doString(` local checks = {} checks[1] = type(string.format) == "function" @@ -253,7 +262,7 @@ describe('Node environment', () => { }) it('error handling with pcall should succeed', async () => { - const state = (await Lua.load()).createState() + const state = (await LuaRuntime.load()).createState() const result = await state.doString(` local ok, err = pcall(function() error("node test error") @@ -263,6 +272,101 @@ describe('Node environment', () => { expect(result).to.be.true }) + it('custom stdout should keep multi byte characters intact', async () => { + const output = await collectOutput('stdout', 'print("héllo 日本語 🎉")') + expect(output).to.be.deep.equal(['héllo 日本語 🎉']) + }) + + it('custom stderr should keep multi byte characters intact', async () => { + const errors = await collectOutput('stderr', 'io.stderr:write("erro ✗ 日本\\n")') + expect(errors).to.be.deep.equal(['erro ✗ 日本']) + }) + + it('custom stdout should keep a character that is flushed in the middle intact', async () => { + // The two halves of 日 land in different writes, with the engine handing control back to + // JS in between. + const output = [] + const lua = await LuaRuntime.load({ stdout: (content) => output.push(content) }) + const state = lua.createState() + state.set( + 'pause', + () => + new Promise((resolve) => { + setTimeout(resolve, 0) + }), + ) + await state.doString('io.write("\\xE6") io.flush() pause():await() io.write("\\x97\\xA5 ok\\n")') + expect(output).to.be.deep.equal(['日 ok']) + }) + + it('custom stdout should split on line breaks only', async () => { + const output = await collectOutput('stdout', 'print("first") print("") io.write("a\\rb\\n")') + expect(output).to.be.deep.equal(['first', '', 'a\rb']) + }) + + it('custom stdout should receive flushed output that does not end in a line break', async () => { + const output = await collectOutput('stdout', 'io.write("no newline") io.flush()') + expect(output).to.be.deep.equal(['no newline']) + }) + + it('custom stdout should not split a line that is written in parts', async () => { + const output = await collectOutput('stdout', 'io.write("one ") io.flush() io.write("line\\n")') + expect(output).to.be.deep.equal(['one line']) + }) + + it('custom stdout should handle lines longer than its buffer', async () => { + const output = await collectOutput('stdout', 'print(string.rep("ção ", 1000))') + expect(output).to.be.deep.equal(['ção '.repeat(1000)]) + }) + + it('custom stdin should be read line by line', async () => { + const lua = await LuaRuntime.load({ stdin: stdinFrom(['um\n', 'dois\n']) }) + const result = await lua.createState().doString('return { io.read("l"), io.read("l"), io.read("l") == nil }') + expect(result).to.be.deep.equal(['um', 'dois', true]) + }) + + it('custom stdin should keep multi byte characters intact', async () => { + const lua = await LuaRuntime.load({ stdin: stdinFrom(['héllo 日本語 🎉\n']) }) + const result = await lua.createState().doString('return io.read("l")') + expect(result).to.be.equal('héllo 日本語 🎉') + }) + + it('custom stdin should be read until it signals the end of the input', async () => { + const lua = await LuaRuntime.load({ stdin: stdinFrom(['abc\n', 'déf\n']) }) + const result = await lua.createState().doString('return io.read("a")') + expect(result).to.be.equal('abc\ndéf\n') + }) + + it('cli should read piped input from within a script', async function () { + this.timeout(30_000) + const { code, stdout, stderr } = await runCli( + ['-e', 'print(io.read("l")) print(io.read("l")) print(io.read("l") == nil)'], + 'olá mundo\nsegunda linha\n', + ) + + expect(stderr).to.be.empty + expect(code).to.be.equal(0) + expect(stdout).to.be.equal('olá mundo\nsegunda linha\ntrue\n') + }) + + it('cli should not add a line break to input that does not end with one', async function () { + this.timeout(30_000) + const { code, stdout, stderr } = await runCli(['-e', 'print(#io.read("a"))'], 'abc') + + expect(stderr).to.be.empty + expect(code).to.be.equal(0) + expect(stdout).to.be.equal('3\n') + }) + + it('cli should not run piped input as a script when -e is given', async function () { + this.timeout(30_000) + const { code, stdout, stderr } = await runCli(['-e', 'print("from -e")'], 'this is not lua code\n') + + expect(stderr).to.be.empty + expect(code).to.be.equal(0) + expect(stdout).to.be.equal('from -e\n') + }) + it('cli should expose arg as a lua table', async function () { this.timeout(30_000) const directory = mkdtempSync(join(tmpdir(), 'wasmoon-cli-')) diff --git a/test/nodefs.test.js b/test/nodefs.test.js index 27f5d9d..c83320e 100644 --- a/test/nodefs.test.js +++ b/test/nodefs.test.js @@ -2,7 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node import { join } from 'node:path' import { tmpdir } from 'node:os' import { expect } from 'chai' -import { Lua } from '../dist/index.js' +import { LuaRuntime } from '../dist/index.js' const createTempDir = () => { const dir = join(tmpdir(), `wasmoon-nodefs-${Date.now()}-${Math.random().toString(36).slice(2)}`) @@ -24,7 +24,7 @@ describe('Node FS', () => { it('read a host file should succeed', async () => { writeFileSync(join(tempDir, 'hello.txt'), 'hello world') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` local f = io.open("${join(tempDir, 'hello.txt').replace(/\\/g, '/')}", "r") @@ -39,7 +39,7 @@ describe('Node FS', () => { it('write a file to host should succeed', async () => { const filePath = join(tempDir, 'output.txt') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() await state.doString(` local f = io.open("${filePath.replace(/\\/g, '/')}", "w") @@ -54,7 +54,7 @@ describe('Node FS', () => { const filePath = join(tempDir, 'append.txt') writeFileSync(filePath, 'first line\n') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() await state.doString(` local f = io.open("${filePath.replace(/\\/g, '/')}", "a") @@ -68,7 +68,7 @@ describe('Node FS', () => { it('read file line by line should succeed', async () => { writeFileSync(join(tempDir, 'lines.txt'), 'alpha\nbeta\ngamma\n') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` local lines = {} @@ -85,7 +85,7 @@ describe('Node FS', () => { const buf = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe]) writeFileSync(join(tempDir, 'binary.dat'), buf) - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` local f = io.open("${join(tempDir, 'binary.dat').replace(/\\/g, '/')}", "rb") @@ -100,7 +100,7 @@ describe('Node FS', () => { it('write and read back binary data should succeed', async () => { const filePath = join(tempDir, 'binout.dat') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() await state.doString(` local f = io.open("${filePath.replace(/\\/g, '/')}", "wb") @@ -114,7 +114,7 @@ describe('Node FS', () => { it('check if file exists using io.open should succeed', async () => { writeFileSync(join(tempDir, 'exists.txt'), 'yes') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` local f = io.open("${join(tempDir, 'exists.txt').replace(/\\/g, '/')}", "r") @@ -134,7 +134,7 @@ describe('Node FS', () => { it('read file size should succeed', async () => { writeFileSync(join(tempDir, 'sized.txt'), 'abcdefghij') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` local f = io.open("${join(tempDir, 'sized.txt').replace(/\\/g, '/')}", "r") @@ -149,7 +149,7 @@ describe('Node FS', () => { it('seek within a file should succeed', async () => { writeFileSync(join(tempDir, 'seek.txt'), 'abcdefghij') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` local f = io.open("${join(tempDir, 'seek.txt').replace(/\\/g, '/')}", "r") @@ -165,7 +165,7 @@ describe('Node FS', () => { it('doFile from host filesystem should succeed', async () => { writeFileSync(join(tempDir, 'script.lua'), 'return 7 * 6') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doFile(join(tempDir, 'script.lua').replace(/\\/g, '/')) @@ -177,8 +177,8 @@ describe('Node FS', () => { mkdirSync(luaDir, { recursive: true }) writeFileSync(join(luaDir, 'mymod.lua'), 'local M = {}; M.value = 99; return M') - const lua = await Lua.load({ fs: 'node' }) - const state = lua.createState({ injectObjects: true }) + const lua = await LuaRuntime.load({ fs: 'node' }) + const state = lua.createState({ inject: true }) const result = await state.doString(` package.path = "${luaDir.replace(/\\/g, '/')}/?.lua;" .. package.path local m = require("mymod") @@ -193,7 +193,7 @@ describe('Node FS', () => { mkdirSync(nested, { recursive: true }) writeFileSync(join(nested, 'deep.txt'), 'deep content') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` local f = io.open("${join(nested, 'deep.txt').replace(/\\/g, '/')}", "r") @@ -208,7 +208,7 @@ describe('Node FS', () => { it('create directory from lua with os.execute should succeed', async () => { const newDir = join(tempDir, 'newdir') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() await state.doString(` os.execute("mkdir -p '${newDir.replace(/\\/g, '/')}'") @@ -221,7 +221,7 @@ describe('Node FS', () => { const filePath = join(tempDir, 'removeme.txt') writeFileSync(filePath, 'to be deleted') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` return os.remove("${filePath.replace(/\\/g, '/')}") @@ -236,7 +236,7 @@ describe('Node FS', () => { const newPath = join(tempDir, 'new.txt') writeFileSync(oldPath, 'rename me') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` return os.rename("${oldPath.replace(/\\/g, '/')}", "${newPath.replace(/\\/g, '/')}") @@ -249,7 +249,7 @@ describe('Node FS', () => { it('doFile from cwd-relative path should succeed', async () => { // NODEFS sets cwd to process.cwd(), so relative doFile should work - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) lua.mountFile('cwd_test.lua', 'return "from cwd"') try { const state = lua.createState() @@ -265,7 +265,7 @@ describe('Node FS', () => { const filePath = join(tempDir, 'large.txt') const lineCount = 10000 - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() await state.doString(` local f = io.open("${filePath.replace(/\\/g, '/')}", "w") @@ -286,7 +286,7 @@ describe('Node FS', () => { const filePath = join(tempDir, 'overwrite.txt') writeFileSync(filePath, 'original content') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() await state.doString(` local f = io.open("${filePath.replace(/\\/g, '/')}", "w") @@ -301,7 +301,7 @@ describe('Node FS', () => { const content = 'héllo wörld 日本語 🌍' writeFileSync(join(tempDir, 'utf8.txt'), content) - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` local f = io.open("${join(tempDir, 'utf8.txt').replace(/\\/g, '/')}", "r") @@ -316,7 +316,7 @@ describe('Node FS', () => { it('write UTF-8 content should succeed', async () => { const filePath = join(tempDir, 'utf8out.txt') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() await state.doString(` local f = io.open("${filePath.replace(/\\/g, '/')}", "w") @@ -330,7 +330,7 @@ describe('Node FS', () => { it('read empty file should succeed', async () => { writeFileSync(join(tempDir, 'empty.txt'), '') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` local f = io.open("${join(tempDir, 'empty.txt').replace(/\\/g, '/')}", "r") @@ -343,7 +343,7 @@ describe('Node FS', () => { }) it('open non-existent file for reading should return nil', async () => { - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` local f, err = io.open("${join(tempDir, 'nonexistent.txt').replace(/\\/g, '/')}", "r") @@ -354,7 +354,7 @@ describe('Node FS', () => { }) it('io.tmpfile should succeed', async () => { - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` local f = io.tmpfile() @@ -371,7 +371,7 @@ describe('Node FS', () => { it('multiple states sharing the same NODEFS should succeed', async () => { const filePath = join(tempDir, 'shared.txt') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state1 = lua.createState() const state2 = lua.createState() @@ -394,7 +394,7 @@ describe('Node FS', () => { it('read number from file should succeed', async () => { writeFileSync(join(tempDir, 'numbers.txt'), '42\n3.14\n100') - const lua = await Lua.load({ fs: 'node' }) + const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` local f = io.open("${join(tempDir, 'numbers.txt').replace(/\\/g, '/')}", "r") diff --git a/test/promises.test.js b/test/promises.test.js index 6227707..0058306 100644 --- a/test/promises.test.js +++ b/test/promises.test.js @@ -6,9 +6,9 @@ describe('Promises', () => { it('use promise next should succeed', async () => { const state = await getState() const check = mock.fn() - state.global.set('check', check) + state.set('check', check) const promise = new Promise((resolve) => setTimeout(() => resolve(60), 5)) - state.global.set('promise', promise) + state.set('promise', promise) const res = state.doString(` promise:next(check) @@ -23,9 +23,9 @@ describe('Promises', () => { it('chain promises with next should succeed', async () => { const state = await getState() const check = mock.fn() - state.global.set('check', check) + state.set('check', check) const promise = new Promise((resolve) => resolve(60)) - state.global.set('promise', promise) + state.set('promise', promise) const res = state.doString(` promise:next(function(value) @@ -43,9 +43,9 @@ describe('Promises', () => { it('call an async function should succeed', async () => { const state = await getState() - state.global.set('asyncFunction', async () => Promise.resolve(60)) + state.set('asyncFunction', async () => Promise.resolve(60)) const check = mock.fn() - state.global.set('check', check) + state.set('check', check) const res = state.doString(` asyncFunction():next(check) @@ -58,7 +58,7 @@ describe('Promises', () => { it('return an async function should succeed', async () => { const state = await getState() - state.global.set('asyncFunction', async () => Promise.resolve(60)) + state.set('asyncFunction', async () => Promise.resolve(60)) const asyncFunction = await state.doString(` return asyncFunction @@ -70,7 +70,7 @@ describe('Promises', () => { it('return a chained promise should succeed', async () => { const state = await getState() - state.global.set('asyncFunction', async () => Promise.resolve(60)) + state.set('asyncFunction', async () => Promise.resolve(60)) const asyncFunction = await state.doString(` return asyncFunction():next(function(x) return x * 2 end) @@ -83,9 +83,9 @@ describe('Promises', () => { it('await an promise inside coroutine should succeed', async () => { const state = await getState() const check = mock.fn() - state.global.set('check', check) + state.set('check', check) const promise = new Promise((resolve) => setTimeout(() => resolve(60), 5)) - state.global.set('promise', promise) + state.set('promise', promise) const res = state.doString(` local co = coroutine.create(function() @@ -110,9 +110,9 @@ describe('Promises', () => { it('awaited coroutines should ignore resume until it resolves the promise', async () => { const state = await getState() const check = mock.fn() - state.global.set('check', check) + state.set('check', check) const promise = new Promise((resolve) => setTimeout(() => resolve(60), 5)) - state.global.set('promise', promise) + state.set('promise', promise) const res = state.doString(` local co = coroutine.create(function() @@ -134,8 +134,8 @@ describe('Promises', () => { it('await a thread run with async calls should succeed', async () => { const state = await getState() - state.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) - const asyncThread = state.global.newThread() + state.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) + const asyncThread = state.newThread() asyncThread.loadString(` sleep(1):await() @@ -148,8 +148,8 @@ describe('Promises', () => { it('run thread with async calls and yields should succeed', async () => { const state = await getState() - state.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) - const asyncThread = state.global.newThread() + state.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) + const asyncThread = state.newThread() asyncThread.loadString(` coroutine.yield() @@ -166,8 +166,8 @@ describe('Promises', () => { it('reject a promise should succeed', async () => { const state = await getState() - state.global.set('throw', () => new Promise((_, reject) => reject(new Error('expected test error')))) - const asyncThread = state.global.newThread() + state.set('throw', () => new Promise((_, reject) => reject(new Error('expected test error')))) + const asyncThread = state.newThread() asyncThread.loadString(` throw():await() @@ -179,8 +179,8 @@ describe('Promises', () => { it('pcall a promise await should succeed', async () => { const state = await getState() - state.global.set('throw', () => new Promise((_, reject) => reject(new Error('expected test error')))) - const asyncThread = state.global.newThread() + state.set('throw', () => new Promise((_, reject) => reject(new Error('expected test error')))) + const asyncThread = state.newThread() asyncThread.loadString(` local succeed, err = pcall(function() throw():await() end) @@ -195,8 +195,8 @@ describe('Promises', () => { const state = await getState() const fulfilled = mock.fn() const rejected = mock.fn() - state.global.set('handlers', { fulfilled, rejected }) - state.global.set('throw', new Promise((_, reject) => reject(new Error('expected test error')))) + state.set('handlers', { fulfilled, rejected }) + state.set('throw', new Promise((_, reject) => reject(new Error('expected test error')))) const res = state.doString(` throw:next(handlers.fulfilled, handlers.rejected):catch(function() end) @@ -210,9 +210,9 @@ describe('Promises', () => { it('run with async callback', async () => { const state = await getState() - const thread = state.global.newThread() + const thread = state.newThread() - state.global.set('asyncCallback', async (input) => { + state.set('asyncCallback', async (input) => { return Promise.resolve(input * 2) }) @@ -262,7 +262,7 @@ describe('Promises', () => { it('resolve multiple promises with promise.all', async () => { const state = await getState() - state.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) + state.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) const resPromise = state.doString(` local promises = {} for i = 1, 10 do @@ -279,7 +279,7 @@ describe('Promises', () => { it('error in promise next catchable', async () => { const state = await getState() - state.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) + state.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) const resPromise = state.doString(` return sleep(1):next(function () error("sleep done") @@ -290,10 +290,10 @@ describe('Promises', () => { it('should not be possible to await in synchronous run', async () => { const state = await getState() - state.global.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) + state.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) expect(() => { state.doStringSync(`sleep(5):await()`) - }).to.throw('cannot await in the main thread') + }).to.throw('cannot await in a thread that cannot yield') }) }) diff --git a/test/engine.test.js b/test/state.test.js similarity index 73% rename from test/engine.test.js rename to test/state.test.js index c5846de..74c1c2d 100644 --- a/test/engine.test.js +++ b/test/state.test.js @@ -1,5 +1,5 @@ import { EventEmitter } from 'events' -import { LuaLibraries, LuaReturn, LuaThread, LuaType, decorate, decorateProxy, decorateUserdata } from '../dist/index.js' +import { LuaReturn, LuaThread, LuaType, decorate } from '../dist/index.js' import { expect } from 'chai' import { getState, getLua } from './utils.js' import { setTimeout } from 'node:timers/promises' @@ -36,20 +36,20 @@ describe('State', () => { it('receive lua table on JS function should succeed', async () => { const state = await getState() - state.global.set('stringify', (table) => { + state.set('stringify', (table) => { return JSON.stringify(table) }) await state.doString('value = stringify({ test = 1 })') - expect(state.global.get('value')).to.be.equal(JSON.stringify({ test: 1 })) + expect(state.get('value')).to.be.equal(JSON.stringify({ test: 1 })) }) it('get a global table inside a JS function called by lua should succeed', async () => { const state = await getState() - state.global.set('t', { test: 1 }) - state.global.set('test', () => { - return state.global.get('t') + state.set('t', { test: 1 }) + state.set('test', () => { + return state.get('t') }) const value = await state.doString('return test(2)') @@ -60,7 +60,7 @@ describe('State', () => { it('receive JS object on lua should succeed', async () => { const state = await getState() - state.global.set('test', () => { + state.set('test', () => { return { aaaa: 1, bbb: 'hey', @@ -80,7 +80,7 @@ describe('State', () => { hello: 'world', } obj.self = obj - state.global.set('obj', obj) + state.set('obj', obj) const value = await state.doString('return obj.self.self.self.hello') @@ -149,7 +149,7 @@ describe('State', () => { hello: 'everybody', } obj2.self = obj2 - state.global.set('obj', { obj1, obj2 }) + state.set('obj', { obj1, obj2 }) await state.doString(` assert(obj.obj1.self.self.hello == "world") @@ -161,7 +161,7 @@ describe('State', () => { const state = await getState() const obj = Object.create(null) obj.hello = 'world' - state.global.set('obj', obj) + state.set('obj', obj) const value = await state.doString(`return obj.hello`) @@ -169,10 +169,10 @@ describe('State', () => { }) it('inherited properties should not be copied into the lua table', async () => { - const state = await getState({ enableProxy: false }) + const state = await getState({ objects: 'copy' }) const obj = Object.create({ inherited: 'yes' }) obj.own = 'mine' - state.global.set('t', obj) + state.set('t', obj) const keys = await state.doString(` local out = {} @@ -185,18 +185,18 @@ describe('State', () => { }) it('non enumerable properties should not be copied into the lua table', async () => { - const state = await getState({ enableProxy: false }) + const state = await getState({ objects: 'copy' }) const obj = { own: 'mine' } Object.defineProperty(obj, 'hidden', { value: 1, enumerable: false }) - state.global.set('t', obj) + state.set('t', obj) expect(await state.doString('return t.hidden == nil')).to.be.true expect(await state.doString('return t.own')).to.be.equal('mine') }) it('nested arrays and objects should be copied into the lua table', async () => { - const state = await getState({ enableProxy: false }) - state.global.set('t', { list: [10, 20, 30], nested: { deep: [{ value: 5 }] } }) + const state = await getState({ objects: 'copy' }) + state.set('t', { list: [10, 20, 30], nested: { deep: [{ value: 5 }] } }) expect(await state.doString('return #t.list')).to.be.equal(3) expect(await state.doString('return t.list[1]')).to.be.equal(10) @@ -213,14 +213,14 @@ describe('State', () => { const state = await getState() await state.doString(`function sum(x, y) return x + y end`) - const sum = state.global.get('sum') + const sum = state.get('sum') expect(sum(10, 50)).to.be.equal(60) }) it('scheduled lua calls should succeed', async () => { const state = await getState() - state.global.set('setInterval', setIntervalSafe) + state.set('setInterval', setIntervalSafe) await state.doString(` test = "" @@ -236,31 +236,28 @@ describe('State', () => { `) const deadline = Date.now() + 1000 - while (!state.global.get('done')) { + while (!state.get('done')) { if (Date.now() > deadline) { throw new Error('timed out waiting for scheduled lua calls') } await setTimeout(5) } - expect(state.global.get('test')).to.be.equal('iiiii') + expect(state.get('test')).to.be.equal('iiiii') }) - it('scheduled lua calls should fail silently if invalid', async () => { + it('calling a lua function after close should throw', async () => { const state = await getState() - state.global.set('setInterval', setIntervalSafe) - const originalConsoleWarn = console.warn - console.warn = mock.fn() - await state.doString(` + const callback = await state.doString(` test = 0 - setInterval(function() + return function() test = test + 1 - end, 5) + end `) - state.global.close() - await setTimeout(5 + 5) - console.warn = originalConsoleWarn + state.close() + + expect(() => callback()).to.throw('cannot call a Lua function after its state has been closed') }) it('call lua function from JS passing an array argument should succeed', async () => { @@ -288,7 +285,7 @@ describe('State', () => { end `) - const returns = state.global.call('f', 10, 25) + const returns = state.call('f', 10, 25) expect(returns).to.have.length(6) expect(returns.slice(0, -1)).to.eql([1, 10, 25, 'Hello World', {}]) expect(returns.at(-1)).to.be.a('function') @@ -310,8 +307,8 @@ describe('State', () => { it('a JS error should pause lua execution', async () => { const state = await getState() const check = mock.fn() - state.global.set('check', check) - state.global.set('throw', () => { + state.set('check', check) + state.set('throw', () => { throw new Error('expected error') }) @@ -327,8 +324,8 @@ describe('State', () => { it('catch a JS error with pcall should succeed', async () => { const state = await getState() const check = mock.fn() - state.global.set('check', check) - state.global.set('throw', () => { + state.set('check', check) + state.set('throw', () => { throw new Error('expected error') }) @@ -345,7 +342,7 @@ describe('State', () => { it('call a JS function in a different thread should succeed', async () => { const state = await getState() const sum = mock.fn((x, y) => x + y) - state.global.set('sum', sum) + state.set('sum', sum) await state.doString(` coroutine.resume(coroutine.create(function() @@ -367,15 +364,15 @@ describe('State', () => { }) `) - state.global.lua.lua_getglobal(state.global.address, 'sum') - const sum = state.global.getValue(-1, LuaType.Function) + state.lua.lua_getglobal(state.address, 'sum') + const sum = state.getValue(-1, LuaType.Function) expect(sum(10, 30)).to.be.equal(40) }) it('lua_resume with yield succeeds', async () => { const state = await getState() - const thread = state.global.newThread() + const thread = state.newThread() thread.loadString(` local yieldRes = coroutine.yield(10) return yieldRes @@ -400,12 +397,12 @@ describe('State', () => { }) it('get memory with allocation tracing should succeeds', async () => { - const state = await getState({ traceAllocations: true }) - expect(state.global.getMemoryUsed()).to.be.greaterThan(0) + const state = await getState({ memory: { trace: true } }) + expect(state.memory.used).to.be.greaterThan(0) }) it('get memory should return correct', async () => { - const state = await getState({ traceAllocations: true }) + const state = await getState({ memory: { trace: true } }) const totalMemory = await state.doString(` collectgarbage() @@ -414,20 +411,20 @@ describe('State', () => { return collectgarbage('count') * 1024 `) - expect(state.global.getMemoryUsed()).to.be.equal(totalMemory) + expect(state.memory.used).to.be.equal(totalMemory) }) - it('get memory without tracing should throw', async () => { - const state = await getState({ traceAllocations: false }) + it('memory is undefined without tracing', async () => { + const state = await getState() - expect(() => state.global.getMemoryUsed()).to.throw() + expect(state.memory).to.be.undefined }) it('limit memory use causes program loading failure succeeds', async () => { - const state = await getState({ traceAllocations: true }) - state.global.setMemoryMax(state.global.getMemoryUsed()) + const state = await getState({ memory: { trace: true } }) + state.memory.max = state.memory.used expect(() => { - state.global.loadString(` + state.loadString(` local a = 10 local b = 20 return a + b @@ -435,8 +432,8 @@ describe('State', () => { }).to.throw('not enough memory') // Remove the limit and retry - state.global.setMemoryMax(undefined) - state.global.loadString(` + state.memory.max = undefined + state.loadString(` local a = 10 local b = 20 return a + b @@ -444,16 +441,16 @@ describe('State', () => { }) it('limit memory use causes program runtime failure succeeds', async () => { - const state = await getState({ traceAllocations: true }) - state.global.loadString(` + const state = await getState({ memory: { trace: true } }) + state.loadString(` local tab = {} for i = 1, 50, 1 do tab[i] = i end `) - state.global.setMemoryMax(state.global.getMemoryUsed()) + state.memory.max = state.memory.used - await expect(state.global.run()).to.eventually.be.rejectedWith('not enough memory') + await expect(state.run()).to.eventually.be.rejectedWith('not enough memory') }) it('table supported circular dependencies', async () => { @@ -464,19 +461,19 @@ describe('State', () => { b.a = a a.b = b - state.global.pushValue(a) - const res = state.global.getValue(-1) + state.pushValue(a) + const res = state.getValue(-1) expect(res.b.a).to.be.eql(res) }) it('wrap a js object (with metatable)', async () => { const state = await getState() - state.global.set('TestClass', { + state.set('TestClass', { create: (name) => { return decorate( { - instance: decorateUserdata(new TestClass(name)), + instance: decorate(new TestClass(name), { as: 'userdata' }), }, { metatable: { @@ -502,7 +499,7 @@ describe('State', () => { it('wrap a js object using proxy', async () => { const state = await getState() - state.global.set('TestClass', { + state.set('TestClass', { create: (name) => new TestClass(name), }) const res = await state.doString(` @@ -514,7 +511,7 @@ describe('State', () => { it('wrap a js object using proxy and apply metatable in lua', async () => { const state = await getState() - state.global.set('TestClass', { + state.set('TestClass', { create: (name) => new TestClass(name), }) const res = await state.doString(` @@ -543,7 +540,7 @@ describe('State', () => { it('classes should be a userdata when proxied', async () => { const state = await getState() - state.global.set('obj', { TestClass }) + state.set('obj', { TestClass }) const testClass = await state.doString(` return obj.TestClass @@ -554,18 +551,18 @@ describe('State', () => { it('timeout blocking lua program', async () => { const state = await getState() - state.global.loadString(` + state.loadString(` local i = 0 while true do i = i + 1 end `) - await expect(state.global.run(0, { timeout: 5 })).eventually.to.be.rejectedWith('thread timeout exceeded') + await expect(state.run(0, { timeout: 5 })).eventually.to.be.rejectedWith('thread timeout exceeded') }) it('the most recently set timeout should be the one that applies', async function () { this.timeout(30_000) const state = await getState() - const thread = state.global.newThread() + const thread = state.newThread() thread.setTimeout(Date.now() + 20) thread.setTimeout(Date.now() + 20_000) @@ -577,7 +574,7 @@ describe('State', () => { it('clearing a timeout should disable the hook', async function () { this.timeout(30_000) const state = await getState() - const thread = state.global.newThread() + const thread = state.newThread() thread.setTimeout(Date.now() + 10) thread.setTimeout(undefined) @@ -591,8 +588,8 @@ describe('State', () => { const state = await getState() let output = '' - state.global.getTable(LuaLibraries.Base, (index) => { - state.global.setField(index, 'print', (val) => { + state.getTable('_G', (index) => { + state.setField(index, 'print', (val) => { // Not a proper print implementation. output += `${val}\n` }) @@ -614,7 +611,7 @@ describe('State', () => { metatable: { __index: (_, k) => `Hello ${k}!` }, }, ) - state.global.set('obj', obj) + state.set('obj', obj) const res = await state.doString('return obj.World') @@ -624,9 +621,9 @@ describe('State', () => { it('a userdata should be collected', async () => { const state = await getState() const obj = {} - state.global.set('obj', obj) - const refIndex = state.global.lua.getLastRefIndex() - const oldRef = state.global.lua.getRef(refIndex) + state.set('obj', obj) + const refIndex = state.lua.getLastRefIndex() + const oldRef = state.lua.getRef(refIndex) await state.doString(` local weaktable = {} @@ -638,7 +635,7 @@ describe('State', () => { `) expect(oldRef).to.be.equal(obj) - const newRef = state.global.lua.getRef(refIndex) + const newRef = state.lua.getRef(refIndex) expect(newRef).to.be.equal(undefined) }) @@ -653,7 +650,7 @@ describe('State', () => { it('static methods should be callable on classes', async () => { const state = await getState() - state.global.set('TestClass', TestClass) + state.set('TestClass', TestClass) const testHello = await state.doString(`return TestClass.hello()`) @@ -664,7 +661,7 @@ describe('State', () => { const state = await getState() const testFunction = () => undefined testFunction.hello = 'world' - state.global.set('TestFunction', decorateProxy(testFunction, { proxy: true })) + state.set('TestFunction', decorate(testFunction, { as: 'proxy' })) const testHello = await state.doString(`return TestFunction.hello`) @@ -685,11 +682,15 @@ describe('State', () => { throw new Error('should not be reached') } catch (err) { expect(err.message).to.includes('[string "..."]:3: function a threw error') - expect(err.message).to.includes('stack traceback:') - expect(err.message).to.includes(`[string "..."]:3: in upvalue 'a'`) - expect(err.message).to.includes(`[string "..."]:5: in upvalue 'b'`) - expect(err.message).to.includes(`[string "..."]:6: in local 'c'`) - expect(err.message).to.includes(`[string "..."]:7: in main chunk`) + expect(err.message).to.not.includes('stack traceback:') + expect(err.luaMessage).to.be.equal(err.message) + expect(err.traceback).to.includes('stack traceback:') + expect(err.traceback).to.includes(`[string "..."]:3: in upvalue 'a'`) + expect(err.traceback).to.includes(`[string "..."]:5: in upvalue 'b'`) + expect(err.traceback).to.includes(`[string "..."]:6: in local 'c'`) + expect(err.traceback).to.includes(`[string "..."]:7: in main chunk`) + // Still reachable through the default logging path. + expect(err.stack).to.includes('stack traceback:') } }) @@ -709,11 +710,11 @@ describe('State', () => { it('should get only the return values on call function', async () => { const state = await getState() - state.global.set('hello', (name) => `Hello ${name}!`) + state.set('hello', (name) => `Hello ${name}!`) const a = await state.doString(`return 1`) const b = state.doStringSync(`return 5`) - const values = state.global.call('hello', 'joao') + const values = state.call('hello', 'joao') expect(a).to.be.equal(1) expect(b).to.be.equal(5) @@ -725,7 +726,7 @@ describe('State', () => { const state = await getState() const str = 'a'.repeat(1000000) - state.global.set('str', str) + state.set('str', str) const res = await state.doString('return str') @@ -746,7 +747,7 @@ describe('State', () => { const state = await getState() const str = 'á'.repeat(500000) - state.global.set('str', str) + state.set('str', str) expect(await state.doString('return #str')).to.be.equal(1000000) expect(await state.doString('return str')).to.be.equal(str) @@ -754,7 +755,7 @@ describe('State', () => { it('a string containing NUL should be pushed with its full length', async () => { const state = await getState() - state.global.set('str', 'a\0b') + state.set('str', 'a\0b') expect(await state.doString('return #str')).to.be.equal(3) }) @@ -770,14 +771,14 @@ describe('State', () => { it('a string containing NUL should round trip unchanged', async () => { const state = await getState() const str = 'before\0middle\0after' - state.global.set('str', str) + state.set('str', str) expect(await state.doString('return str')).to.be.equal(str) }) it('an empty string should round trip unchanged', async () => { const state = await getState() - state.global.set('str', '') + state.set('str', '') expect(await state.doString('return #str')).to.be.equal(0) expect(await state.doString('return str')).to.be.equal('') @@ -787,39 +788,39 @@ describe('State', () => { const state = await getState() await state.doString('value = string.char(0, 255, 128, 65)') - state.global.lua.lua_getglobal(state.global.address, 'value') - const bytes = state.global.getStringBytes(-1) - state.global.pop() + state.lua.lua_getglobal(state.address, 'value') + const bytes = state.getStringBytes(-1) + state.pop() expect(Array.from(bytes)).to.be.eql([0, 255, 128, 65]) }) it('getStringBytes should return undefined for a non string value', async () => { const state = await getState() - state.global.pushValue({}) + state.pushValue({}) - expect(state.global.getStringBytes(-1)).to.be.undefined + expect(state.getStringBytes(-1)).to.be.undefined - state.global.pop() + state.pop() }) it('bytecode should round trip through the byte accessors', async () => { const state = await getState() await state.doString('bytecode = string.dump(load("return 42"))') - state.global.lua.lua_getglobal(state.global.address, 'bytecode') - const bytes = state.global.getStringBytes(-1) - state.global.pop() + state.lua.lua_getglobal(state.address, 'bytecode') + const bytes = state.getStringBytes(-1) + state.pop() - state.global.pushStringBytes(bytes) - state.global.lua.lua_setglobal(state.global.address, 'roundtripped') + state.pushStringBytes(bytes) + state.lua.lua_setglobal(state.address, 'roundtripped') expect(await state.doString('return load(roundtripped)()')).to.be.equal(42) }) it('negative integers should be pushed and retrieved as string', async () => { const state = await getState() - state.global.set('value', -1) + state.set('value', -1) const res = await state.doString(`return tostring(value)`) @@ -828,7 +829,7 @@ describe('State', () => { it('negative integers should be pushed and retrieved as number', async () => { const state = await getState() - state.global.set('value', -1) + state.set('value', -1) const res = await state.doString(`return value`) @@ -838,7 +839,7 @@ describe('State', () => { it('number greater than 32 bit int should be pushed and retrieved as string', async () => { const state = await getState() const value = 1689031554550 - state.global.set('value', value) + state.set('value', value) const res = await state.doString(`return tostring(value)`) @@ -848,7 +849,7 @@ describe('State', () => { it('number greater than 32 bit int should be pushed and retrieved as number', async () => { const state = await getState() const value = 1689031554550 - state.global.set('value', value) + state.set('value', value) const res = await state.doString(`return value`) @@ -858,7 +859,7 @@ describe('State', () => { it('number greater than 32 bit int should be usable as a format argument', async () => { const state = await getState() const value = 1689031554550 - state.global.set('value', value) + state.set('value', value) const res = await state.doString(`return ("%d"):format(value)`) @@ -869,15 +870,15 @@ describe('State', () => { const state = await getState() const value = 9223372036854775807n - state.global.lua.lua_pushinteger(state.global.address, value) - state.global.lua.lua_setglobal(state.global.address, 'value') + state.lua.lua_pushinteger(state.address, value) + state.lua.lua_setglobal(state.address, 'value') const asString = await state.doString(`return tostring(value)`) const asFormatted = await state.doString(`return ("%d"):format(value)`) - state.global.lua.lua_getglobal(state.global.address, 'value') - const roundTrip = state.global.lua.lua_tointegerx(state.global.address, -1, null) - state.global.pop() + state.lua.lua_getglobal(state.address, 'value') + const roundTrip = state.lua.lua_tointegerx(state.address, -1, null) + state.pop() expect(asString).to.be.equal('9223372036854775807') expect(asFormatted).to.be.equal('9223372036854775807') @@ -902,7 +903,7 @@ describe('State', () => { it('an integral number should be pushed as a lua integer', async () => { const state = await getState() - state.global.set('value', 2) + state.set('value', 2) expect(await state.doString('return math.type(value)')).to.be.equal('integer') }) @@ -916,7 +917,7 @@ describe('State', () => { it('a bigint should be pushed as a 64 bit lua integer', async () => { const state = await getState() - state.global.set('value', 9223372036854775807n) + state.set('value', 9223372036854775807n) expect(await state.doString('return tostring(value)')).to.be.equal('9223372036854775807') expect(await state.doString('return math.type(value)')).to.be.equal('integer') @@ -926,12 +927,12 @@ describe('State', () => { it('a bigint outside the lua integer range should throw', async () => { const state = await getState() - expect(() => state.global.set('value', 2n ** 70n)).to.throw(RangeError) + expect(() => state.set('value', 2n ** 70n)).to.throw(RangeError) }) it('an integral number too large for a lua integer should be pushed as a float', async () => { const state = await getState() - state.global.set('value', 1e300) + state.set('value', 1e300) expect(await state.doString('return math.type(value)')).to.be.equal('float') expect(await state.doString('return value')).to.be.equal(1e300) @@ -943,7 +944,7 @@ describe('State', () => { // re-used and doesn't cause JS to abort or some nonsense. const state = await getState() const testEmitter = new EventEmitter() - state.global.set('yield', () => new Promise((resolve) => testEmitter.once('resolve', resolve))) + state.set('yield', () => new Promise((resolve) => testEmitter.once('resolve', resolve))) const resPromise = state.doString(` local res = yield():next(function () coroutine.yield() @@ -959,9 +960,9 @@ describe('State', () => { }) it('forced yield within JS callback from Lua doesnt cause vm to crash', async () => { - const state = await getState({ functionTimeout: 10 }) - state.global.set('promise', Promise.resolve()) - const thread = state.global.newThread() + const state = await getState({ limits: { functionTimeout: 10 } }) + state.set('promise', Promise.resolve()) + const thread = state.newThread() thread.loadString(` promise:next(function () while true do @@ -976,8 +977,8 @@ describe('State', () => { it('function callback timeout still allows timeout of caller thread', async () => { const state = await getState() - state.global.set('promise', Promise.resolve()) - const thread = state.global.newThread() + state.set('promise', Promise.resolve()) + const thread = state.newThread() thread.loadString(` promise:next(function () -- nothing @@ -989,25 +990,25 @@ describe('State', () => { it('null injected and valid', async () => { const state = await getState() - state.global.loadString(` + state.loadString(` local args = { ... } assert(args[1] == null, string.format("expected first argument to be null, got %s", tostring(args[1]))) return null, args[1], tostring(null) `) - state.global.pushValue(null) - const res = await state.global.run(1) + state.pushValue(null) + const res = await state.run(1) expect(res).to.deep.equal([null, null, 'null']) }) it('null injected as nil', async () => { - const state = await getState({ injectObjects: false }) - state.global.loadString(` + const state = await getState({ inject: false }) + state.loadString(` local args = { ... } assert(type(args[1]) == "nil", string.format("expected first argument to be nil, got %s", type(args[1]))) return nil, args[1], tostring(nil) `) - state.global.pushValue(null) - const res = await state.global.run(1) + state.pushValue(null) + const res = await state.run(1) expect(res).to.deep.equal([null, null, 'nil']) }) @@ -1015,22 +1016,22 @@ describe('State', () => { const state = await getState() await state.doString('null = 5') - state.global.set('value', null) + state.set('value', null) expect(await state.doString('return type(value)')).to.be.equal('userdata') - expect(state.global.get('value')).to.be.null + expect(state.get('value')).to.be.null }) it('a pushed null should equal the injected null global', async () => { const state = await getState() - state.global.set('value', null) + state.set('value', null) expect(await state.doString('return value == null')).to.be.true }) it('Nested callback from JS to Lua', async () => { const state = await getState() - state.global.set('call', (fn) => fn()) + state.set('call', (fn) => fn()) const res = await state.doString(` return call(function () return call(function () @@ -1056,22 +1057,22 @@ describe('State', () => { it('lots of doStringSync calls should not grow the lua stack', async function () { this.timeout(30_000) const state = await getState() - const startTop = state.global.getTop() + const startTop = state.getTop() for (let i = 0; i < 500; i++) { expect(state.doStringSync(`return ${i}`)).to.be.equal(i) } - expect(state.global.getTop()).to.be.equal(startTop) + expect(state.getTop()).to.be.equal(startTop) }) it('a failing doStringSync should not grow the lua stack', async () => { const state = await getState() - const startTop = state.global.getTop() + const startTop = state.getTop() expect(() => state.doStringSync('error("boom")')).to.throw('boom') - expect(state.global.getTop()).to.be.equal(startTop) + expect(state.getTop()).to.be.equal(startTop) }) it('values returned by doStringSync should outlive the stack reset', async () => { @@ -1100,3 +1101,130 @@ describe('State', () => { } }) }) + +describe('Load mode', () => { + it('loadString refuses bytecode by default', async () => { + const state = await getState() + // Produced and reloaded inside Lua so the bytes never round trip through a JS string. + expect( + state.doStringSync(` + local d = string.dump(function() return 42 end) + local f, err = load(d, 'c', 't') + return err + `), + ).to.include('attempt to load a binary chunk') + }) + + it('the host loader refuses a binary chunk by default', async () => { + const state = await getState() + + expect(() => state.loadString('\x1bLua\x55\x00')).to.throw('attempt to load a binary chunk') + }) + + it('mode bt opts back in', async () => { + const state = await getState() + + expect( + state.doStringSync(` + local d = string.dump(function() return 42 end) + return load(d, 'c', 'bt')() + `), + ).to.be.equal(42) + }) +}) + +describe('Decoration', () => { + class Thing { + constructor(name) { + this.name = name + } + getName() { + return this.name + } + } + + it('as userdata hides the members', async () => { + const state = await getState() + state.set('thing', decorate(new Thing('bob'), { as: 'userdata' })) + + // An opaque userdata has no __index of its own, so Lua refuses to index it at all. + await expect(state.doString('return thing.name')).to.eventually.be.rejectedWith('attempt to index a js_userdata value') + }) + + it('as userdata still cannot take a metatable of its own', async () => { + const state = await getState() + + // The userdata extension owns the metatable slot. That is a constraint of the extension, + // not of the decoration API, so a custom metatable has to go on a wrapper. + expect(() => state.set('thing', decorate(new Thing('bob'), { as: 'userdata', metatable: { __name: 'js_thing' } }))).to.throw( + 'data already has associated metatable: js_userdata', + ) + }) + + it('a wrapper carries the metatable around an opaque userdata', async () => { + const state = await getState() + state.set( + 'thing', + decorate( + { inner: decorate(new Thing('bob'), { as: 'userdata' }) }, + { metatable: { __name: 'js_thing', __index: (self, key) => (key === 'name' ? self.inner.getName() : null) } }, + ), + ) + + expect(await state.doString('return thing.name')).to.be.equal('bob') + }) + + it('as value copies an object instead of proxying it', async () => { + const state = await getState() + state.set('plain', decorate({ a: 1 }, { as: 'value' })) + + expect(await state.doString('return type(plain)')).to.be.equal('table') + }) + + it('as proxy forces a function through the proxy layer', async () => { + const state = await getState() + const fn = () => undefined + fn.hello = 'world' + state.set('fn', decorate(fn, { as: 'proxy' })) + + expect(await state.doString('return fn.hello')).to.be.equal('world') + }) +}) + +describe('Memory', () => { + it('memory.max rejects without tracing', async () => { + const lua = await getLua() + + expect(() => lua.createState({ memory: { max: 1000 } })).to.throw('requires memory.trace') + }) + + it('memory is a live view', async () => { + using state = await getState({ memory: { trace: true } }) + + const before = state.memory.used + await state.doString('big = {} for i = 1, 5000 do big[i] = i end') + + expect(state.memory.used).to.be.greaterThan(before) + }) +}) + +describe('Synchronous runs', () => { + it('do not leak stack onto the state between calls', async () => { + const state = await getState() + const top = state.getTop() + + for (let i = 0; i < 50; i++) { + state.doStringSync('return 1') + } + + expect(state.getTop()).to.be.equal(top) + }) + + it('a hook installed for a sync run does not outlive it', async () => { + const state = await getState() + + state.doStringSync('return 1', { timeout: 1000 }) + + expect(state.getTimeout()).to.be.undefined + }) +}) diff --git a/test/utils.js b/test/utils.js index 8a0e97d..ee59609 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,13 +1,13 @@ -import { Lua } from '../dist/index.js' +import { LuaRuntime } from '../dist/index.js' export const getLua = (env) => { - return Lua.load({ env }) + return LuaRuntime.load({ env }) } export const getState = async (config = {}) => { - const lua = await Lua.load() + const lua = await LuaRuntime.load() return lua.createState({ - injectObjects: true, + inject: true, ...config, }) } diff --git a/utils/build-wasm.sh b/utils/build-wasm.sh index 046430c..c234f59 100755 --- a/utils/build-wasm.sh +++ b/utils/build-wasm.sh @@ -29,7 +29,6 @@ emcc \ 'lengthBytesUTF8', \ 'stringToUTF8', \ 'stringToNewUTF8', \ - 'intArrayFromString', \ 'UTF8ToString', \ 'HEAPU8', \ 'HEAPU32' From e4a27b90a248f90a414e3c734176e906027d2d8c Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Tue, 28 Jul 2026 20:30:16 -0300 Subject: [PATCH 42/52] better types --- .oxlintrc.json | 10 +--- package.json | 4 +- src/declarations.d.ts | 12 ++++- src/decoration.ts | 16 ++++-- src/index.ts | 19 +++++-- src/module.ts | 96 +++++++++++++++++++-------------- src/multireturn.ts | 23 +++++++- src/pointer.ts | 1 - src/runtime.ts | 19 ++----- src/state.ts | 27 +++++----- src/thread.ts | 50 +++++++++-------- src/type-extension.ts | 34 ++++++++---- src/type-extensions/error.ts | 48 +++++++---------- src/type-extensions/function.ts | 80 ++++++++++++--------------- src/type-extensions/null.ts | 62 ++++++++++----------- src/type-extensions/promise.ts | 80 +++++++++++++-------------- src/type-extensions/proxy.ts | 67 +++++++++++------------ src/type-extensions/table.ts | 27 +++++----- src/type-extensions/userdata.ts | 39 ++++++-------- src/types.ts | 59 ++++++++++++-------- src/utils.ts | 11 +++- test/.oxlintrc.json | 5 ++ test/errors.test.js | 2 +- test/state.test.js | 12 ++--- tsconfig.json | 5 +- 25 files changed, 434 insertions(+), 374 deletions(-) delete mode 100644 src/pointer.ts create mode 100644 test/.oxlintrc.json diff --git a/.oxlintrc.json b/.oxlintrc.json index d39faa0..c9c4b84 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -14,13 +14,5 @@ "ignorePatterns": ["dist/**", "build/**", "rolldown.config.ts", "rolldown.config.*.js", "utils/**"], "env": { "builtin": true - }, - "overrides": [ - { - "files": ["**/test/**"], - "rules": { - "no-unused-expressions": "off" - } - } - ] + } } diff --git a/package.json b/package.json index 45023dd..2724d80 100755 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "luatests": "node --experimental-import-meta-resolve test/luatests.js", "build": "rolldown -c && tsc -d --emitDeclarationOnly --rootDir src --declarationDir dist", "clean": "rm -rf dist build", - "lint": "oxfmt --write . && oxlint --config .oxlintrc.json --fix .", - "lint:nofix": "oxfmt --check . && oxlint --config .oxlintrc.json ." + "lint": "oxfmt --write . && oxlint --fix .", + "lint:nofix": "oxfmt --check . && oxlint ." }, "files": [ "bin/*", diff --git a/src/declarations.d.ts b/src/declarations.d.ts index 2f74c40..d1df7e0 100644 --- a/src/declarations.d.ts +++ b/src/declarations.d.ts @@ -3,7 +3,17 @@ declare module '*.wasm' { export default value } -declare module '*.js' +// The emscripten glue is generated JS, so it has no types of its own. Declaring the factory rather +// than the whole module keeps the init options checked against what the build actually accepts +// (see INCOMING_MODULE_JS_API in utils/build-wasm.sh). +declare module '*/glue.js' { + const initWasmModule: ( + moduleArg?: Partial> & { + preRun?: (module: import('./module').LuaEmscriptenModule) => void + }, + ) => Promise + export default initWasmModule +} declare module 'package-version' { const value: string diff --git a/src/decoration.ts b/src/decoration.ts index ff77c32..04d57ac 100755 --- a/src/decoration.ts +++ b/src/decoration.ts @@ -17,19 +17,25 @@ export class Decoration { ) {} } +/** + * A metatable to attach to a pushed value. Decorating it controls how it is pushed; on its own it + * is copied into a plain Lua table. + */ +export type LuaMetatable = object | Decoration + export interface DecorationOptions { /** Metatable to attach to the pushed value, itself decoratable to control how it is pushed. */ - metatable?: Record | Decoration - as?: DecorationTarget + metatable?: LuaMetatable | undefined + as?: DecorationTarget | undefined /** Function only: bound as the receiver, and dropped from the argument list. */ - self?: any + self?: unknown /** Function only: receives the calling thread as the first argument. */ - receiveThread?: boolean + receiveThread?: boolean | undefined /** * Function only: receives the argument count instead of the decoded arguments, and is * expected to read the stack itself. This is a raw-level escape hatch. */ - receiveArgsQuantity?: boolean + receiveArgsQuantity?: boolean | undefined } /** diff --git a/src/index.ts b/src/index.ts index bd40719..81cc3d4 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,23 @@ export { default as LuaRuntime } from './runtime' export { default as LuaState, type LuaMemory } from './state' -export { default as LuaThread } from './thread' +export { default as LuaThread, type OrderedExtension } from './thread' export { default as LuaMultiReturn } from './multireturn' export { default as LuaRawResult } from './raw-result' -export { decorate, Decoration, type DecorationOptions, type DecorationTarget } from './decoration' +export { decorate, Decoration, type DecorationOptions, type DecorationTarget, type LuaMetatable } from './decoration' // Export the underlying bindings to allow users to just // use the bindings rather than the wrappers. -export { default as LuaModule } from './module' +export { + default as LuaModule, + type EmscriptenPath, + type EnvironmentVariables, + type LuaEmscriptenModule, + type LuaModuleOptions, +} from './module' export { default as LuaTypeExtension } from './type-extension' +// The built in extensions are not exported, but the value types they marshal are, so a custom +// extension can describe what it handles. +export type { FunctionType } from './type-extensions/function' +export type { TableType } from './type-extensions/table' // Named rather than `export *`, so the library bitmask helpers and the warn default stay internal. export { LuaError, @@ -23,6 +33,7 @@ export { LUA_REGISTRYINDEX, LUAI_MAXSTACK, LUA_LIB_BITS, + PointerSize, type LuaAddress, type LuaLibName, type LuaLoadMode, @@ -35,6 +46,8 @@ export { type LuaDoOptions, type LuaThreadLimits, type LuaResumeResult, + type LuaGetCache, + type LuaPushCache, } from './types' import LuaRuntime from './runtime' diff --git a/src/module.ts b/src/module.ts index 4f46613..5e096de 100755 --- a/src/module.ts +++ b/src/module.ts @@ -1,26 +1,34 @@ import initWasmModule from '../build/glue.js' -import { defaultWarnHandler, LUA_REGISTRYINDEX, LuaAddress, LuaReturn, LuaType, LuaWarnHandler, PointerSize } from './types.js' +import { defaultWarnHandler, LUA_REGISTRYINDEX, type LuaAddress, LuaReturn, LuaType, type LuaWarnHandler, PointerSize } from './types' // A rolldown plugin will resolve this to the current version on package.json import version from 'package-version' -type EnvironmentVariables = Record +export type EnvironmentVariables = Record -interface LuaEmscriptenModule extends EmscriptenModule { +/** Emscripten's own path helpers, which work on the virtual filesystem rather than the host one. */ +export interface EmscriptenPath { + isAbs: (path: string) => boolean + normalize: (path: string) => string + dirname: (path: string) => string + basename: (path: string) => string + join: (...paths: string[]) => string + join2: (left: string, right: string) => string +} + +export interface LuaEmscriptenModule extends EmscriptenModule { ccall: typeof ccall addFunction: typeof addFunction removeFunction: typeof removeFunction setValue: typeof setValue getValue: typeof getValue + // `filesystems` is the only member missing upstream; mkdirTree and the rest come from typeof FS. FS: typeof FS & { filesystems: { NODEFS: Emscripten.FileSystemType MEMFS: Emscripten.FileSystemType } - mkdirTree: (path: string) => void - } - PATH: { - dirname: (typeof import('node:path'))['dirname'] } + PATH: EmscriptenPath stringToNewUTF8: typeof stringToNewUTF8 lengthBytesUTF8: typeof lengthBytesUTF8 stringToUTF8: typeof stringToUTF8 @@ -29,6 +37,27 @@ interface LuaEmscriptenModule extends EmscriptenModule { _realloc: (pointer: number, size: number) => number } +export interface LuaModuleOptions { + /** Custom URI for the Lua WebAssembly module. Defaults to unpkg in the browser. */ + wasmFile?: string | undefined + /** Environment variables for the Lua states. */ + env?: EnvironmentVariables | undefined + /** File system that should be used. */ + fs?: 'node' | 'memory' | undefined + /** Host directories to mount when `fs` is `'node'`. Defaults to the drive holding the CWD. */ + fsMountPaths?: string[] | undefined + /** Called once per read. An empty string (or nothing) means EOF. */ + stdin?: (() => string | null | undefined) | undefined + /** + * Called with each completed line, without the line break, and with a partial line when Lua + * flushes or suspends in the middle of one. + */ + stdout?: ((content: string) => void) | undefined + stderr?: ((content: string) => void) | undefined + /** Where load time diagnostics go. Defaults to `console.warn`. */ + onWarn?: LuaWarnHandler | undefined +} + // One-shot conversions, so a single stateless pair is shared by every module. Streaming output // keeps its own decoder in createOutputWriter. const textDecoder = new TextDecoder() @@ -46,35 +75,23 @@ interface ReferenceMetadata { } export default class LuaModule { - public static async initialize(opts: { - wasmFile?: string - env?: EnvironmentVariables - fs?: 'node' | 'memory' - fsMountPaths?: string[] - // Called once per read, and an empty string (or nothing) means EOF. - stdin?: () => string | null | undefined - // Called with each completed line, without the line break, and with a partial line when - // Lua flushes or suspends in the middle of one. - stdout?: (content: string) => void - stderr?: (content: string) => void - /** Where load time diagnostics go. Defaults to `console.warn`. */ - onWarn?: LuaWarnHandler - }): Promise { + public static async initialize(opts: Readonly = {}): Promise { const warn = opts.onWarn ?? defaultWarnHandler const isBrowser = (typeof window === 'object' && typeof window.document !== 'undefined') || (typeof self === 'object' && self?.constructor?.name === 'DedicatedWorkerGlobalScope') - if (opts.wasmFile === undefined && isBrowser) { - opts.wasmFile = `https://unpkg.com/wasmoon@${version}/dist/glue.wasm` - } - const useNodeFS = !isBrowser && opts.fs === 'node' && typeof process !== 'undefined' const fs = useNodeFS ? await import('node:fs') : null - const module: LuaEmscriptenModule = await initWasmModule({ + const module = await initWasmModule({ locateFile: (path: string, scriptDirectory: string) => { - return opts.wasmFile || scriptDirectory + path + // The wasm sits next to the bundle when it was built alongside it, which is not the + // case for a browser loading the published package. + if (opts.wasmFile) { + return opts.wasmFile + } + return isBrowser ? `https://unpkg.com/wasmoon@${version}/dist/glue.wasm` : scriptDirectory + path }, preRun: (initializedModule: LuaEmscriptenModule) => { if (typeof opts?.env === 'object') { @@ -160,15 +177,16 @@ export default class LuaModule { public luaL_optlstring: (L: LuaAddress, arg: number, def: string | null, l: number | null) => string public luaL_checknumber: (L: LuaAddress, arg: number) => number public luaL_optnumber: (L: LuaAddress, arg: number, def: number) => number - public luaL_checkinteger: (L: LuaAddress, arg: number) => number - public luaL_optinteger: (L: LuaAddress, arg: number, def: number) => number + // lua_Integer is 64 bit, and the module is built with WASM_BIGINT, so these cross as BigInt. + public luaL_checkinteger: (L: LuaAddress, arg: number) => bigint + public luaL_optinteger: (L: LuaAddress, arg: number, def: bigint) => bigint public luaL_checkstack: (L: LuaAddress, sz: number, msg: string | null) => void public luaL_checktype: (L: LuaAddress, arg: number, t: number) => void public luaL_checkany: (L: LuaAddress, arg: number) => void public luaL_newmetatable: (L: LuaAddress, tname: string | null) => number public luaL_setmetatable: (L: LuaAddress, tname: string | null) => void - public luaL_testudata: (L: LuaAddress, ud: number, tname: string | null) => number - public luaL_checkudata: (L: LuaAddress, ud: number, tname: string | null) => number + public luaL_testudata: (L: LuaAddress, ud: number, tname: string | null) => LuaAddress + public luaL_checkudata: (L: LuaAddress, ud: number, tname: string | null) => LuaAddress public luaL_where: (L: LuaAddress, lvl: number) => void public luaL_fileresult: (L: LuaAddress, stat: number, fname: string | null) => number public luaL_execresult: (L: LuaAddress, stat: number) => number @@ -184,7 +202,7 @@ export default class LuaModule { ) => LuaReturn public luaL_loadstring: (L: LuaAddress, s: string | null) => LuaReturn public luaL_newstate: () => LuaAddress - public luaL_len: (L: LuaAddress, idx: number) => number + public luaL_len: (L: LuaAddress, idx: number) => bigint public luaL_addgsub: (b: number | null, s: string | null, p: string | null, r: string | null) => void public luaL_gsub: (L: LuaAddress, s: string | null, p: string | null, r: string | null) => string public luaL_setfuncs: (L: LuaAddress, l: number | null, nup: number) => void @@ -227,9 +245,9 @@ export default class LuaModule { public lua_toboolean: (L: LuaAddress, idx: number) => number public lua_rawlen: (L: LuaAddress, idx: number) => bigint public lua_tocfunction: (L: LuaAddress, idx: number) => number - public lua_touserdata: (L: LuaAddress, idx: number) => number + public lua_touserdata: (L: LuaAddress, idx: number) => LuaAddress public lua_tothread: (L: LuaAddress, idx: number) => LuaAddress - public lua_topointer: (L: LuaAddress, idx: number) => number + public lua_topointer: (L: LuaAddress, idx: number) => LuaAddress public lua_arith: (L: LuaAddress, op: number) => void public lua_rawequal: (L: LuaAddress, idx1: number, idx2: number) => number public lua_compare: (L: LuaAddress, idx1: number, idx2: number, op: number) => number @@ -245,11 +263,11 @@ export default class LuaModule { public lua_gettable: (L: LuaAddress, idx: number) => LuaType public lua_getfield: (L: LuaAddress, idx: number, k: string | null) => LuaType public lua_geti: (L: LuaAddress, idx: number, n: bigint) => LuaType - public lua_rawget: (L: LuaAddress, idx: number) => number + public lua_rawget: (L: LuaAddress, idx: number) => LuaType public lua_rawgeti: (L: LuaAddress, idx: number, n: bigint) => LuaType public lua_rawgetp: (L: LuaAddress, idx: number, p: number | null) => LuaType public lua_createtable: (L: LuaAddress, narr: number, nrec: number) => void - public lua_newuserdatauv: (L: LuaAddress, sz: number, nuvalue: number) => number + public lua_newuserdatauv: (L: LuaAddress, sz: number, nuvalue: number) => LuaAddress public lua_getmetatable: (L: LuaAddress, objindex: number) => number public lua_getiuservalue: (L: LuaAddress, idx: number, n: number) => LuaType public lua_setglobal: (L: LuaAddress, name: string | null) => void @@ -262,7 +280,7 @@ export default class LuaModule { public lua_setmetatable: (L: LuaAddress, objindex: number) => number public lua_setiuservalue: (L: LuaAddress, idx: number, n: number) => number public lua_callk: (L: LuaAddress, nargs: number, nresults: number, ctx: number, k: number | null) => void - public lua_pcallk: (L: LuaAddress, nargs: number, nresults: number, errfunc: number, ctx: number, k: number | null) => number + public lua_pcallk: (L: LuaAddress, nargs: number, nresults: number, errfunc: number, ctx: number, k: number | null) => LuaReturn public lua_load: (L: LuaAddress, reader: number | null, dt: number | null, chunkname: string | null, mode: string | null) => LuaReturn public lua_dump: (L: LuaAddress, writer: number | null, data: number | null, strip: number) => number public lua_yieldk: (L: LuaAddress, nresults: number, ctx: number, k: number | null) => number @@ -286,7 +304,7 @@ export default class LuaModule { public lua_setlocal: (L: LuaAddress, ar: number | null, n: number) => string public lua_getupvalue: (L: LuaAddress, funcindex: number, n: number) => string public lua_setupvalue: (L: LuaAddress, funcindex: number, n: number) => string - public lua_upvalueid: (L: LuaAddress, fidx: number, n: number) => number + public lua_upvalueid: (L: LuaAddress, fidx: number, n: number) => LuaAddress public lua_upvaluejoin: (L: LuaAddress, fidx1: number, n1: number, fidx2: number, n2: number) => void public lua_sethook: (L: LuaAddress, func: number | null, mask: number, count: number) => void public lua_gethook: (L: LuaAddress) => number @@ -611,7 +629,7 @@ export default class LuaModule { } } - public getRef(index: number): any | undefined { + public getRef(index: number): unknown { return this.referenceMap.get(index) } diff --git a/src/multireturn.ts b/src/multireturn.ts index 5d0c7a6..a3085bc 100644 --- a/src/multireturn.ts +++ b/src/multireturn.ts @@ -1 +1,22 @@ -export default class MultiReturn extends Array {} +/** + * Several values returned from one JS function, pushed as several Lua values rather than as a + * single table. + * + * ```js + * state.set('divide', (a, b) => MultiReturn.of(Math.floor(a / b), a % b)) + * ``` + */ +export default class MultiReturn extends Array { + /** + * Nominal marker. Whether a returned array becomes several Lua values or one Lua table is + * decided by `instanceof`, so the type has to be distinguishable from a plain array too. It is + * declared rather than assigned, so nothing is added to the instance at runtime. + */ + declare protected readonly multiReturn: undefined + + /** + * Only the type differs from the inherited `Array.of`, which already constructs through `this` + * and so already returns a MultiReturn. + */ + declare public static of: (...items: T[]) => MultiReturn +} diff --git a/src/pointer.ts b/src/pointer.ts deleted file mode 100644 index 8e0688c..0000000 --- a/src/pointer.ts +++ /dev/null @@ -1 +0,0 @@ -export class Pointer extends Number {} diff --git a/src/runtime.ts b/src/runtime.ts index c012de8..30ca443 100755 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,6 +1,6 @@ -import LuaModule from './module' +import LuaModule, { type EmscriptenPath, type LuaModuleOptions } from './module' import LuaState from './state' -import { CreateStateOptions } from './types' +import type { CreateStateOptions } from './types' /** * One loaded Lua wasm module, and the factory for the states that run on it. @@ -9,17 +9,8 @@ import { CreateStateOptions } from './types' * otherwise independent. */ export default class LuaRuntime { - /** - * Loads the Lua wasm module. - * @param opts.wasmFile - Custom URI for the Lua WebAssembly module. - * @param opts.env - Environment variables for the Lua states. - * @param opts.stdin - Standard input, shared by every state on this runtime. - * @param opts.fs - File system that should be used. - * @param opts.stdout - Standard output, shared by every state on this runtime. - * @param opts.stderr - Standard error, shared by every state on this runtime. - * @param opts.onWarn - Where load time diagnostics go. Defaults to `console.warn`. - */ - public static async load(luaModuleOpts: Parameters[0] = {}): Promise { + /** Loads the Lua wasm module. Stdio and the filesystem are shared by every state on it. */ + public static async load(luaModuleOpts: Readonly = {}): Promise { return new LuaRuntime(await LuaModule.initialize(luaModuleOpts)) } @@ -64,7 +55,7 @@ export default class LuaRuntime { return this.module._emscripten.FS } - public get path(): typeof this.module._emscripten.PATH { + public get path(): EmscriptenPath { return this.module._emscripten.PATH } } diff --git a/src/state.ts b/src/state.ts index 0bcca4a..c7abc93 100755 --- a/src/state.ts +++ b/src/state.ts @@ -1,6 +1,6 @@ import type LuaModule from './module' import Thread from './thread' -import LuaTypeExtension from './type-extension' +import type LuaTypeExtension from './type-extension' import createErrorType from './type-extensions/error' import createFunctionType from './type-extensions/function' import createNullType from './type-extensions/null' @@ -9,12 +9,12 @@ import createProxyType from './type-extensions/proxy' import createTableType from './type-extensions/table' import createUserdataType from './type-extensions/userdata' import { - CreateStateOptions, + type CreateStateOptions, LUA_REGISTRYINDEX, - LuaAddress, - LuaDoOptions, - LuaMemoryOptions, - LuaRunOptions, + type LuaAddress, + type LuaDoOptions, + type LuaMemoryOptions, + type LuaRunOptions, LuaType, resolveLibraryMask, } from './types' @@ -118,7 +118,7 @@ export default class LuaState extends Thread { // Generic handlers - These may be required to be registered for additional types. this.registerTypeExtension(0, createTableType(this)) - this.registerTypeExtension(0, createFunctionType(this, { functionTimeout: limits?.functionTimeout })) + this.registerTypeExtension(0, createFunctionType(this, limits?.functionTimeout)) // Contains the :await functionality. this.registerTypeExtension(1, createPromiseType(this, inject)) @@ -153,9 +153,10 @@ export default class LuaState extends Thread { /** * Executes Lua code from a string asynchronously. - * @returns A Promise that resolves to the result returned by the Lua script execution. + * @returns A Promise that resolves to the result returned by the Lua script execution. Nothing + * checks it against `T`, which only saves the caller a cast. */ - public doString(script: string, options?: LuaDoOptions): Promise { + public doString(script: string, options?: LuaDoOptions): Promise { return this.callByteCode((thread) => thread.loadString(script, options), this.runOptions(options)) } @@ -163,7 +164,7 @@ export default class LuaState extends Thread { * Executes Lua code from a file asynchronously. * @returns A Promise that resolves to the result returned by the Lua script execution. */ - public doFile(filename: string, options?: LuaDoOptions): Promise { + public doFile(filename: string, options?: LuaDoOptions): Promise { return this.callByteCode((thread) => thread.loadFile(filename, options), this.runOptions(options)) } @@ -171,7 +172,7 @@ export default class LuaState extends Thread { * Executes Lua code from a string synchronously. The script cannot yield, so `:await()` and a * top level `coroutine.yield` are errors here. */ - public doStringSync(script: string, options?: LuaDoOptions): any { + public doStringSync(script: string, options?: LuaDoOptions): T { return this.callByteCodeSync((thread) => thread.loadString(script, options), this.runOptions(options)) } @@ -179,7 +180,7 @@ export default class LuaState extends Thread { * Executes Lua code from a file synchronously. The script cannot yield, so `:await()` and a * top level `coroutine.yield` are errors here. */ - public doFileSync(filename: string, options?: LuaDoOptions): any { + public doFileSync(filename: string, options?: LuaDoOptions): T { return this.callByteCodeSync((thread) => thread.loadFile(filename, options), this.runOptions(options)) } @@ -194,7 +195,7 @@ export default class LuaState extends Thread { } /** Retrieves the value of a global variable. */ - public get(name: string): any { + public get(name: string): T { const type = this.lua.lua_getglobal(this.address, name) const value = this.getValue(-1, type) this.pop() diff --git a/src/thread.ts b/src/thread.ts index be92daf..b49c346 100755 --- a/src/thread.ts +++ b/src/thread.ts @@ -1,25 +1,26 @@ -import { Decoration } from './decoration' +import { Decoration, type LuaMetatable } from './decoration' import type LuaModule from './module' import MultiReturn from './multireturn' -import { Pointer } from './pointer' -import LuaTypeExtension from './type-extension' +import type LuaTypeExtension from './type-extension' import { defaultWarnHandler, LUA_MULTRET, LuaAbortError, - LuaAddress, + type LuaAddress, LuaError, LuaEventMasks, + type LuaGetCache, LuaInstructionLimitError, LuaInterruptError, - LuaLoadOptions, - LuaResumeResult, + type LuaLoadOptions, + type LuaPushCache, + type LuaResumeResult, LuaReturn, - LuaRunOptions, - LuaThreadLimits, + type LuaRunOptions, + type LuaThreadLimits, LuaTimeoutError, LuaType, - LuaWarnHandler, + type LuaWarnHandler, PointerSize, } from './types' import { isEmscriptenUnwind, isPromise, yieldToEventLoop } from './utils' @@ -43,7 +44,7 @@ export default class Thread { /** Set on the root state; threads created from it delegate here. */ public onWarn: LuaWarnHandler | undefined protected readonly typeExtensions: OrderedExtension[] - protected readonly parent?: Thread + protected readonly parent: Thread | undefined private closed = false private hookFunctionPointer: number | undefined private hookCount = INSTRUCTION_HOOK_COUNT @@ -172,7 +173,7 @@ export default class Thread { const restore = this.applyRunOptions(options) try { const base = this.getTop() - argCount - 1 // The 1 is for the function to run - this.assertOk(this.lua.lua_pcallk(this.address, argCount, LUA_MULTRET, 0, 0, null) as LuaReturn) + this.assertOk(this.lua.lua_pcallk(this.address, argCount, LUA_MULTRET, 0, 0, null)) return this.getStackValues(base) } finally { restore() @@ -213,7 +214,7 @@ export default class Thread { return L === this.parent?.address ? this.parent : new Thread(this.lua, this.typeExtensions, L, this.parent || this) } - public pushValue(rawValue: unknown, userdata?: unknown): void { + public pushValue(rawValue: unknown, cache?: LuaPushCache): void { const decoratedValue = this.getValueDecorations(rawValue) const target = decoratedValue.target @@ -254,7 +255,7 @@ export default class Thread { this.lua.lua_pushboolean(this.address, target ? 1 : 0) break default: - if (this.typeExtensions.find((wrapper) => wrapper.extension.pushValue(this, decoratedValue, userdata))) { + if (this.typeExtensions.find((wrapper) => wrapper.extension.pushValue(this, decoratedValue, cache))) { break } if (target === null) { @@ -273,7 +274,7 @@ export default class Thread { } } - public setMetatable(index: number, metatable: Record): void { + public setMetatable(index: number, metatable: LuaMetatable): void { index = this.lua.lua_absindex(this.address, index) if (this.lua.lua_getmetatable(this.address, index)) { @@ -305,7 +306,7 @@ export default class Thread { return name } - public getValue(index: number, inputType?: LuaType, userdata?: unknown): any { + public getValue(index: number, inputType?: LuaType, cache?: LuaGetCache): any { index = this.lua.lua_absindex(this.address, index) const type: LuaType = inputType ?? this.lua.lua_type(this.address, index) @@ -340,7 +341,7 @@ export default class Thread { wrapper.extension.isType(this, index, type, metatableName), ) if (typeExtensionWrapper) { - return typeExtensionWrapper.extension.getValue(this, index, userdata) + return typeExtensionWrapper.extension.getValue(this, index, cache) } // Handing back an opaque Pointer hid the failure until the value was used, and @@ -386,13 +387,17 @@ export default class Thread { return { ...this.limits } } - /** Set to > 0 to enable, otherwise disable. Shorthand for the deadline in {@link setLimits}. */ - public setTimeout(timeout: number | undefined): void { - this.limits.deadline = timeout && timeout > 0 ? timeout : undefined + /** + * Shorthand for the deadline in {@link setLimits}. The argument is an absolute timestamp as + * returned by `Date.now()`, not a duration — pass `Date.now() + ms`, or undefined to disable. + * `LuaRunOptions.timeout` is the per-run equivalent that does take a duration. + */ + public setDeadline(deadline: number | undefined): void { + this.limits.deadline = deadline && deadline > 0 ? deadline : undefined this.applyHook() } - public getTimeout(): number | undefined { + public getDeadline(): number | undefined { return this.limits.deadline } @@ -402,8 +407,9 @@ export default class Thread { ;(root.onWarn ?? defaultWarnHandler)(message, cause) } - public getPointer(index: number): Pointer { - return new Pointer(this.lua.lua_topointer(this.address, index)) + /** For identity checks on values JS cannot represent. */ + public getPointer(index: number): LuaAddress { + return this.lua.lua_topointer(this.address, index) } public isClosed(): boolean { diff --git a/src/type-extension.ts b/src/type-extension.ts index 3bac4ae..95c1940 100644 --- a/src/type-extension.ts +++ b/src/type-extension.ts @@ -1,15 +1,16 @@ -import { Decoration } from './decoration' +import type { Decoration } from './decoration' import type LuaState from './state' -import Thread from './thread' -import { LuaType, PointerSize } from './types' +import type Thread from './thread' +import { type LuaAddress, type LuaGetCache, type LuaPushCache, LuaReturn, LuaType, PointerSize } from './types' export default abstract class LuaTypeExtension { // Type name, for metatables and lookups. public readonly name: string - protected thread: LuaState + /** Owns this extension's metatable and function pointers, so its lifetime bounds theirs. */ + protected state: LuaState - public constructor(thread: LuaState, name: string) { - this.thread = thread + public constructor(state: LuaState, name: string) { + this.state = state this.name = name } @@ -19,19 +20,34 @@ export default abstract class LuaTypeExtension { public abstract close(): void + /** + * The `__gc` handler every reference holding extension needs. The caller owns the returned + * pointer and has to release it with `removeFunction` in {@link close}. + */ + protected createGcFunction(): number { + return this.state.lua._emscripten.addFunction((calledL: LuaAddress) => { + // Throws a lua error which does a jump if it does not match. + const userDataPointer = this.state.lua.luaL_checkudata(calledL, 1, this.name) + const referencePointer = this.state.lua._emscripten.getValue(userDataPointer, '*') + this.state.lua.unref(referencePointer) + + return LuaReturn.Ok + }, 'ii') + } + // A base implementation that assumes user data serialisation - public getValue(thread: Thread, index: number, _userdata?: unknown): T { + public getValue(thread: Thread, index: number, _cache?: LuaGetCache): T { const refUserdata = thread.lua.luaL_testudata(thread.address, index, this.name) if (!refUserdata) { throw new Error(`data does not have the expected metatable: ${this.name}`) } const referencePointer = thread.lua._emscripten.getValue(refUserdata, '*') - return thread.lua.getRef(referencePointer) + return thread.lua.getRef(referencePointer) as T } // Return false if type not matched, otherwise true. This base method does not // check the type. That must be done by the class extending this. - public pushValue(thread: Thread, decoratedValue: Decoration, _userdata?: unknown): boolean { + public pushValue(thread: Thread, decoratedValue: Decoration, _cache?: LuaPushCache): boolean { const { target } = decoratedValue const pointer = thread.lua.ref(target) diff --git a/src/type-extensions/error.ts b/src/type-extensions/error.ts index a86f4dc..d376312 100644 --- a/src/type-extensions/error.ts +++ b/src/type-extensions/error.ts @@ -1,58 +1,50 @@ import { Decoration } from '../decoration' import type LuaState from '../state' -import Thread from '../thread' +import type Thread from '../thread' import TypeExtension from '../type-extension' -import { LuaReturn, LuaAddress } from '../types' class ErrorTypeExtension extends TypeExtension { private gcPointer: number - public constructor(thread: LuaState, injectObject: boolean) { - super(thread, 'js_error') + public constructor(state: LuaState, injectObject: boolean) { + super(state, 'js_error') - this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaAddress) => { - // Throws a lua error which does a jump if it does not match. - const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) - const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') - thread.lua.unref(referencePointer) + this.gcPointer = this.createGcFunction() - return LuaReturn.Ok - }, 'ii') - - if (thread.lua.luaL_newmetatable(thread.address, this.name)) { - const metatableIndex = thread.lua.lua_gettop(thread.address) + if (state.lua.luaL_newmetatable(state.address, this.name)) { + const metatableIndex = state.lua.lua_gettop(state.address) // Mark it as uneditable - thread.lua.lua_pushstring(thread.address, 'protected metatable') - thread.lua.lua_setfield(thread.address, metatableIndex, '__metatable') + state.lua.lua_pushstring(state.address, 'protected metatable') + state.lua.lua_setfield(state.address, metatableIndex, '__metatable') // Add the gc function - thread.lua.lua_pushcclosure(thread.address, this.gcPointer, 0) - thread.lua.lua_setfield(thread.address, metatableIndex, '__gc') + state.lua.lua_pushcclosure(state.address, this.gcPointer, 0) + state.lua.lua_setfield(state.address, metatableIndex, '__gc') // Add an __index method that returns the message field - thread.pushValue((jsRefError: Error, key: unknown) => { + state.pushValue((jsRefError: Error, key: unknown) => { if (key === 'message') { return jsRefError.message } return null }) - thread.lua.lua_setfield(thread.address, metatableIndex, '__index') + state.lua.lua_setfield(state.address, metatableIndex, '__index') // Add a tostring method that returns the message. - thread.pushValue((jsRefError: Error) => { + state.pushValue((jsRefError: Error) => { // The message rather than toString to avoid the Error: prefix being // added. This fits better with Lua errors. return jsRefError.message }) - thread.lua.lua_setfield(thread.address, metatableIndex, '__tostring') + state.lua.lua_setfield(state.address, metatableIndex, '__tostring') } // Pop the metatable from the stack. - thread.lua.lua_pop(thread.address, 1) + state.lua.lua_pop(state.address, 1) if (injectObject) { // Lastly create a static Error constructor. - thread.set('Error', { + state.set('Error', { create: (message: string | undefined) => { if (message && typeof message !== 'string') { throw new Error('message must be a string') @@ -64,7 +56,7 @@ class ErrorTypeExtension extends TypeExtension { } } - public pushValue(thread: Thread, decoration: Decoration): boolean { + public pushValue(thread: Thread, decoration: Decoration): boolean { if (!(decoration.target instanceof Error)) { return false } @@ -72,10 +64,10 @@ class ErrorTypeExtension extends TypeExtension { } public close(): void { - this.thread.lua._emscripten.removeFunction(this.gcPointer) + this.state.lua._emscripten.removeFunction(this.gcPointer) } } -export default function createTypeExtension(thread: LuaState, injectObject: boolean): TypeExtension { - return new ErrorTypeExtension(thread, injectObject) +export default function createTypeExtension(state: LuaState, injectObject: boolean): TypeExtension { + return new ErrorTypeExtension(state, injectObject) } diff --git a/src/type-extensions/function.ts b/src/type-extensions/function.ts index ee2d9c5..1ed4a57 100644 --- a/src/type-extensions/function.ts +++ b/src/type-extensions/function.ts @@ -2,21 +2,17 @@ import { Decoration } from '../decoration' import type LuaState from '../state' import MultiReturn from '../multireturn' import RawResult from '../raw-result' -import Thread from '../thread' +import type Thread from '../thread' import TypeExtension from '../type-extension' -import { LUA_REGISTRYINDEX, LuaReturn, LuaAddress, LuaType, PointerSize } from '../types' +import { LUA_REGISTRYINDEX, LuaReturn, type LuaAddress, LuaType, PointerSize } from '../types' import { isEmscriptenUnwind } from '../utils' export type FunctionType = (...args: any[]) => Promise | any -export interface FunctionTypeExtensionOptions { - functionTimeout?: number -} - class FunctionTypeExtension extends TypeExtension { private readonly functionRegistry = new FinalizationRegistry((func: number) => { - if (!this.thread.isClosed()) { - this.thread.lua.luaL_unref(this.thread.address, LUA_REGISTRYINDEX, func) + if (!this.state.isClosed()) { + this.state.lua.luaL_unref(this.state.address, LUA_REGISTRYINDEX, func) } }) @@ -24,51 +20,45 @@ class FunctionTypeExtension extends TypeExtension { private functionWrapper: number private callbackContext: Thread private callbackContextIndex: number - private options?: FunctionTypeExtensionOptions + /** Milliseconds a Lua function called from JS may run before being interrupted. */ + private readonly functionTimeout: number | undefined - public constructor(thread: LuaState, options?: FunctionTypeExtensionOptions) { - super(thread, 'js_function') + public constructor(state: LuaState, functionTimeout?: number) { + super(state, 'js_function') - this.options = options + this.functionTimeout = functionTimeout // Create a thread off of the global thread to be used to create function call threads without // interfering with the global context. This creates a callback context that will always exist // even if the thread that called getValue() has been destroyed. - this.callbackContext = thread.newThread() + this.callbackContext = state.newThread() // Pops it from the global stack but keeps it alive - this.callbackContextIndex = this.thread.lua.luaL_ref(thread.address, LUA_REGISTRYINDEX) + this.callbackContextIndex = this.state.lua.luaL_ref(state.address, LUA_REGISTRYINDEX) if (!this.functionRegistry) { - thread.warn('FunctionTypeExtension: FinalizationRegistry not found. Memory leaks likely.') + state.warn('FunctionTypeExtension: FinalizationRegistry not found. Memory leaks likely.') } - this.gcPointer = thread.lua._emscripten.addFunction((calledL: LuaAddress) => { - // Throws a lua error which does a jump if it does not match. - const userDataPointer = thread.lua.luaL_checkudata(calledL, 1, this.name) - const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') - thread.lua.unref(referencePointer) - - return LuaReturn.Ok - }, 'ii') + this.gcPointer = this.createGcFunction() // Creates metatable if it doesn't exist, always pushes it onto the stack. - if (thread.lua.luaL_newmetatable(thread.address, this.name)) { - thread.lua.lua_pushstring(thread.address, '__gc') - thread.lua.lua_pushcclosure(thread.address, this.gcPointer, 0) - thread.lua.lua_settable(thread.address, -3) - - thread.lua.lua_pushstring(thread.address, '__metatable') - thread.lua.lua_pushstring(thread.address, 'protected metatable') - thread.lua.lua_settable(thread.address, -3) + if (state.lua.luaL_newmetatable(state.address, this.name)) { + state.lua.lua_pushstring(state.address, '__gc') + state.lua.lua_pushcclosure(state.address, this.gcPointer, 0) + state.lua.lua_settable(state.address, -3) + + state.lua.lua_pushstring(state.address, '__metatable') + state.lua.lua_pushstring(state.address, 'protected metatable') + state.lua.lua_settable(state.address, -3) } // Pop the metatable from the stack. - thread.lua.lua_pop(thread.address, 1) + state.lua.lua_pop(state.address, 1) - this.functionWrapper = thread.lua._emscripten.addFunction((calledL: LuaAddress) => { - const calledThread = thread.stateToThread(calledL) + this.functionWrapper = state.lua._emscripten.addFunction((calledL: LuaAddress) => { + const calledThread = state.stateToThread(calledL) - const refUserdata = thread.lua.luaL_checkudata(calledL, thread.lua.lua_upvalueindex(1), this.name) - const refPointer = thread.lua._emscripten.getValue(refUserdata, '*') - const { target, options: decorationOptions } = thread.lua.getRef(refPointer) as Decoration + const refUserdata = state.lua.luaL_checkudata(calledL, state.lua.lua_upvalueindex(1), this.name) + const refPointer = state.lua._emscripten.getValue(refUserdata, '*') + const { target, options: decorationOptions } = state.lua.getRef(refPointer) as Decoration const argsQuantity = calledThread.getTop() const args = [] @@ -115,8 +105,8 @@ class FunctionTypeExtension extends TypeExtension { } public close(): void { - this.thread.lua._emscripten.removeFunction(this.gcPointer) - this.thread.lua._emscripten.removeFunction(this.functionWrapper) + this.state.lua._emscripten.removeFunction(this.gcPointer) + this.state.lua._emscripten.removeFunction(this.functionWrapper) // Doesn't destroy the Lua thread, just function pointers. this.callbackContext.close() // Destroy the Lua thread @@ -127,7 +117,7 @@ class FunctionTypeExtension extends TypeExtension { return type === LuaType.Function } - public pushValue(thread: Thread, decoration: Decoration): boolean { + public pushValue(thread: Thread, decoration: Decoration): boolean { if (typeof decoration.target !== 'function') { return false } @@ -193,11 +183,11 @@ class FunctionTypeExtension extends TypeExtension { callThread.pushValue(arg) } - if (this.options?.functionTimeout) { - callThread.setTimeout(Date.now() + this.options.functionTimeout) + if (this.functionTimeout) { + callThread.setDeadline(Date.now() + this.functionTimeout) } - const status: LuaReturn = callThread.lua.lua_pcallk(callThread.address, args.length, 1, 0, 0, null) + const status = callThread.lua.lua_pcallk(callThread.address, args.length, 1, 0, 0, null) if (status === LuaReturn.Yield) { throw new Error('cannot yield in callbacks from javascript') } @@ -220,6 +210,6 @@ class FunctionTypeExtension extends TypeExtension { } } -export default function createTypeExtension(thread: LuaState, options?: FunctionTypeExtensionOptions): TypeExtension { - return new FunctionTypeExtension(thread, options) +export default function createTypeExtension(state: LuaState, functionTimeout?: number): TypeExtension { + return new FunctionTypeExtension(state, functionTimeout) } diff --git a/src/type-extensions/null.ts b/src/type-extensions/null.ts index b38e310..d6f9c34 100644 --- a/src/type-extensions/null.ts +++ b/src/type-extensions/null.ts @@ -1,59 +1,52 @@ import { Decoration } from '../decoration' import type LuaState from '../state' -import Thread from '../thread' +import type Thread from '../thread' import TypeExtension from '../type-extension' -import { LUA_REGISTRYINDEX, LuaReturn, LuaAddress } from '../types' +import { LUA_REGISTRYINDEX } from '../types' class NullTypeExtension extends TypeExtension { private gcPointer: number private nullReference: number - public constructor(thread: LuaState) { - super(thread, 'js_null') + public constructor(state: LuaState) { + super(state, 'js_null') - this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaAddress) => { - // Throws a lua error which does a jump if it does not match. - const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) - const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') - thread.lua.unref(referencePointer) + this.gcPointer = this.createGcFunction() - return LuaReturn.Ok - }, 'ii') - - if (thread.lua.luaL_newmetatable(thread.address, this.name)) { - const metatableIndex = thread.lua.lua_gettop(thread.address) + if (state.lua.luaL_newmetatable(state.address, this.name)) { + const metatableIndex = state.lua.lua_gettop(state.address) // Mark it as uneditable - thread.lua.lua_pushstring(thread.address, 'protected metatable') - thread.lua.lua_setfield(thread.address, metatableIndex, '__metatable') + state.lua.lua_pushstring(state.address, 'protected metatable') + state.lua.lua_setfield(state.address, metatableIndex, '__metatable') // Add the gc function - thread.lua.lua_pushcclosure(thread.address, this.gcPointer, 0) - thread.lua.lua_setfield(thread.address, metatableIndex, '__gc') + state.lua.lua_pushcclosure(state.address, this.gcPointer, 0) + state.lua.lua_setfield(state.address, metatableIndex, '__gc') // Add an __index method that returns nothing. - thread.pushValue(() => null) - thread.lua.lua_setfield(thread.address, metatableIndex, '__index') + state.pushValue(() => null) + state.lua.lua_setfield(state.address, metatableIndex, '__index') - thread.pushValue(() => 'null') - thread.lua.lua_setfield(thread.address, metatableIndex, '__tostring') + state.pushValue(() => 'null') + state.lua.lua_setfield(state.address, metatableIndex, '__tostring') - thread.pushValue((self: unknown, other: unknown) => self === other) - thread.lua.lua_setfield(thread.address, metatableIndex, '__eq') + state.pushValue((self: unknown, other: unknown) => self === other) + state.lua.lua_setfield(state.address, metatableIndex, '__eq') } // Pop the metatable from the stack. - thread.lua.lua_pop(thread.address, 1) + state.lua.lua_pop(state.address, 1) // Create a new table, this is unique and will be the "null" value by attaching the // metatable created above. The first argument is the target, the second options. - super.pushValue(thread, new Decoration({}, {})) + super.pushValue(state, new Decoration({}, {})) // Lua code is free to reassign the `null` global, so marshalling anchors the sentinel in // the registry instead of looking it up by name. - thread.lua.lua_pushvalue(thread.address, -1) - this.nullReference = thread.lua.luaL_ref(thread.address, LUA_REGISTRYINDEX) + state.lua.lua_pushvalue(state.address, -1) + this.nullReference = state.lua.luaL_ref(state.address, LUA_REGISTRYINDEX) - thread.lua.lua_setglobal(thread.address, 'null') + state.lua.lua_setglobal(state.address, 'null') } public getValue(thread: Thread, index: number): null { @@ -64,9 +57,8 @@ class NullTypeExtension extends TypeExtension { return null } - // any because LuaDecoration is not exported from the Lua lib. - public pushValue(thread: Thread, decoration: any): boolean { - if (decoration?.target !== null) { + public pushValue(thread: Thread, decoration: Decoration): boolean { + if (decoration.target !== null) { return false } thread.lua.lua_rawgeti(thread.address, LUA_REGISTRYINDEX, BigInt(this.nullReference)) @@ -74,10 +66,10 @@ class NullTypeExtension extends TypeExtension { } public close(): void { - this.thread.lua._emscripten.removeFunction(this.gcPointer) + this.state.lua._emscripten.removeFunction(this.gcPointer) } } -export default function createTypeExtension(thread: LuaState): TypeExtension { - return new NullTypeExtension(thread) +export default function createTypeExtension(state: LuaState): TypeExtension { + return new NullTypeExtension(state) } diff --git a/src/type-extensions/promise.ts b/src/type-extensions/promise.ts index a10dead..ad393bd 100644 --- a/src/type-extensions/promise.ts +++ b/src/type-extensions/promise.ts @@ -2,56 +2,52 @@ import { Decoration } from '../decoration' import type LuaState from '../state' import MultiReturn from '../multireturn' import RawResult from '../raw-result' -import Thread from '../thread' +import type Thread from '../thread' import TypeExtension from '../type-extension' -import { LuaReturn, LuaAddress } from '../types' +import type { LuaAddress } from '../types' import { isPromise } from '../utils' import { decorate } from '../decoration' class PromiseTypeExtension extends TypeExtension> { private gcPointer: number - public constructor(thread: LuaState, injectObject: boolean) { - super(thread, 'js_promise') + public constructor(state: LuaState, injectObject: boolean) { + super(state, 'js_promise') - this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaAddress) => { - // Throws a lua error which does a jump if it does not match. - const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) - const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') - thread.lua.unref(referencePointer) + this.gcPointer = this.createGcFunction() - return LuaReturn.Ok - }, 'ii') - - if (thread.lua.luaL_newmetatable(thread.address, this.name)) { - const metatableIndex = thread.lua.lua_gettop(thread.address) + if (state.lua.luaL_newmetatable(state.address, this.name)) { + const metatableIndex = state.lua.lua_gettop(state.address) // Mark it as uneditable - thread.lua.lua_pushstring(thread.address, 'protected metatable') - thread.lua.lua_setfield(thread.address, metatableIndex, '__metatable') + state.lua.lua_pushstring(state.address, 'protected metatable') + state.lua.lua_setfield(state.address, metatableIndex, '__metatable') // Add the gc function - thread.lua.lua_pushcclosure(thread.address, this.gcPointer, 0) - thread.lua.lua_setfield(thread.address, metatableIndex, '__gc') + state.lua.lua_pushcclosure(state.address, this.gcPointer, 0) + state.lua.lua_setfield(state.address, metatableIndex, '__gc') - const checkSelf = (self: Promise): true => { + // A bare thenable reaches here too, and only `then` is guaranteed on one. Adopting it + // into a real promise is what makes catch/finally/await work on it, and is a no-op for + // the native promises that make up almost every case. + const asPromise = (self: unknown): Promise => { if (!isPromise(self)) { throw new Error('self instance is not a promise') } - return true + return Promise.resolve(self) } - thread.pushValue({ - next: (self: Promise, ...args: Parameters) => checkSelf(self) && self.then(...args), - catch: (self: Promise, ...args: Parameters) => checkSelf(self) && self.catch(...args), - finally: (self: Promise, ...args: Parameters) => checkSelf(self) && self.finally(...args), + state.pushValue({ + next: (self: unknown, ...args: Parameters['then']>) => asPromise(self).then(...args), + catch: (self: unknown, ...args: Parameters['catch']>) => asPromise(self).catch(...args), + finally: (self: unknown, ...args: Parameters['finally']>) => asPromise(self).finally(...args), await: decorate( - (functionThread: Thread, self: Promise) => { - checkSelf(self) + (functionThread: Thread, rawSelf: unknown) => { + const self = asPromise(rawSelf) // Asking Lua covers every non-resumable context, not just the main // thread: anything entered through lua_pcall cannot yield either. - if (!thread.lua.lua_isyieldable(functionThread.address)) { + if (!state.lua.lua_isyieldable(functionThread.address)) { throw new Error('cannot await in a thread that cannot yield, use doString instead of doStringSync') } @@ -66,7 +62,7 @@ class PromiseTypeExtension extends TypeExtension> { promiseResult = { status: 'rejected', value: err } }) - const continuance = this.thread.lua._emscripten.addFunction((continuanceState: LuaAddress) => { + const continuance = this.state.lua._emscripten.addFunction((continuanceState: LuaAddress) => { // If this yield has been called from within a coroutine and so manually resumed // then there may not yet be any results. In that case yield again. if (!promiseResult) { @@ -75,16 +71,16 @@ class PromiseTypeExtension extends TypeExtension> { // 0 because this is called between resumes so the first one should've // popped the promise before returning the result. This is true within // Lua's coroutine.resume too. - return thread.lua.lua_yieldk(functionThread.address, 0, 0, continuance) + return state.lua.lua_yieldk(functionThread.address, 0, 0, continuance) } - this.thread.lua._emscripten.removeFunction(continuance) + this.state.lua._emscripten.removeFunction(continuance) - const continuanceThread = thread.stateToThread(continuanceState) + const continuanceThread = state.stateToThread(continuanceState) if (promiseResult.status === 'rejected') { continuanceThread.pushValue(promiseResult.value || new Error('promise rejected with no error')) - return this.thread.lua.lua_error(continuanceState) + return this.state.lua.lua_error(continuanceState) } if (promiseResult.value instanceof RawResult) { @@ -101,22 +97,22 @@ class PromiseTypeExtension extends TypeExtension> { }, 'iiii') functionThread.pushValue(awaitPromise) - return new RawResult(thread.lua.lua_yieldk(functionThread.address, 1, 0, continuance)) + return new RawResult(state.lua.lua_yieldk(functionThread.address, 1, 0, continuance)) }, { receiveThread: true }, ), }) - thread.lua.lua_setfield(thread.address, metatableIndex, '__index') + state.lua.lua_setfield(state.address, metatableIndex, '__index') - thread.pushValue((self: Promise, other: Promise) => self === other) - thread.lua.lua_setfield(thread.address, metatableIndex, '__eq') + state.pushValue((self: Promise, other: Promise) => self === other) + state.lua.lua_setfield(state.address, metatableIndex, '__eq') } // Pop the metatable from the stack. - thread.lua.lua_pop(thread.address, 1) + state.lua.lua_pop(state.address, 1) if (injectObject) { // Lastly create a static Promise constructor. - thread.set('Promise', { + state.set('Promise', { create: (callback: ConstructorParameters[0]) => new Promise(callback), all: (promiseArray: any) => { if (!Array.isArray(promiseArray)) { @@ -131,10 +127,10 @@ class PromiseTypeExtension extends TypeExtension> { } public close(): void { - this.thread.lua._emscripten.removeFunction(this.gcPointer) + this.state.lua._emscripten.removeFunction(this.gcPointer) } - public pushValue(thread: Thread, decoration: Decoration>): boolean { + public pushValue(thread: Thread, decoration: Decoration): boolean { if (!isPromise(decoration.target)) { return false } @@ -142,6 +138,6 @@ class PromiseTypeExtension extends TypeExtension> { } } -export default function createTypeExtension(thread: LuaState, injectObject: boolean): TypeExtension> { - return new PromiseTypeExtension(thread, injectObject) +export default function createTypeExtension(state: LuaState, injectObject: boolean): TypeExtension> { + return new PromiseTypeExtension(state, injectObject) } diff --git a/src/type-extensions/proxy.ts b/src/type-extensions/proxy.ts index f151a0f..a059d18 100644 --- a/src/type-extensions/proxy.ts +++ b/src/type-extensions/proxy.ts @@ -1,38 +1,31 @@ import { decorate, Decoration } from '../decoration' import type LuaState from '../state' import MultiReturn from '../multireturn' -import Thread from '../thread' +import type Thread from '../thread' import TypeExtension from '../type-extension' -import { LuaReturn, LuaAddress, LuaType } from '../types' +import { LuaType } from '../types' import { isPromise } from '../utils' class ProxyTypeExtension extends TypeExtension { private readonly gcPointer: number - public constructor(thread: LuaState) { - super(thread, 'js_proxy') + public constructor(state: LuaState) { + super(state, 'js_proxy') - this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaAddress) => { - // Throws a lua error which does a jump if it does not match. - const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) - const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') - thread.lua.unref(referencePointer) + this.gcPointer = this.createGcFunction() - return LuaReturn.Ok - }, 'ii') - - if (thread.lua.luaL_newmetatable(thread.address, this.name)) { - const metatableIndex = thread.lua.lua_gettop(thread.address) + if (state.lua.luaL_newmetatable(state.address, this.name)) { + const metatableIndex = state.lua.lua_gettop(state.address) // Mark it as uneditable - thread.lua.lua_pushstring(thread.address, 'protected metatable') - thread.lua.lua_setfield(thread.address, metatableIndex, '__metatable') + state.lua.lua_pushstring(state.address, 'protected metatable') + state.lua.lua_setfield(state.address, metatableIndex, '__metatable') // Add the gc function - thread.lua.lua_pushcclosure(thread.address, this.gcPointer, 0) - thread.lua.lua_setfield(thread.address, metatableIndex, '__gc') + state.lua.lua_pushcclosure(state.address, this.gcPointer, 0) + state.lua.lua_setfield(state.address, metatableIndex, '__gc') - thread.pushValue((self: any, key: unknown) => { + state.pushValue((self: any, key: unknown) => { switch (typeof key) { case 'number': // Map from Lua's 1 based indexing to JS's 0. @@ -53,9 +46,9 @@ class ProxyTypeExtension extends TypeExtension { return value }) - thread.lua.lua_setfield(thread.address, metatableIndex, '__index') + state.lua.lua_setfield(state.address, metatableIndex, '__index') - thread.pushValue((self: any, key: unknown, value: any) => { + state.pushValue((self: any, key: unknown, value: any) => { switch (typeof key) { case 'number': // Map from Lua's 1 based indexing to JS's 0. @@ -68,19 +61,19 @@ class ProxyTypeExtension extends TypeExtension { } self[key as string | number] = value }) - thread.lua.lua_setfield(thread.address, metatableIndex, '__newindex') + state.lua.lua_setfield(state.address, metatableIndex, '__newindex') - thread.pushValue((self: any) => { + state.pushValue((self: any) => { return self.toString?.() ?? typeof self }) - thread.lua.lua_setfield(thread.address, metatableIndex, '__tostring') + state.lua.lua_setfield(state.address, metatableIndex, '__tostring') - thread.pushValue((self: any) => { + state.pushValue((self: any) => { return self.length || 0 }) - thread.lua.lua_setfield(thread.address, metatableIndex, '__len') + state.lua.lua_setfield(state.address, metatableIndex, '__len') - thread.pushValue((self: any) => { + state.pushValue((self: any) => { const keys = Object.getOwnPropertyNames(self) let i = 0 // Stateful rather than stateless. First call is with nil. @@ -94,24 +87,24 @@ class ProxyTypeExtension extends TypeExtension { null, ) }) - thread.lua.lua_setfield(thread.address, metatableIndex, '__pairs') + state.lua.lua_setfield(state.address, metatableIndex, '__pairs') - thread.pushValue((self: any, other: any) => { + state.pushValue((self: any, other: any) => { return self === other }) - thread.lua.lua_setfield(thread.address, metatableIndex, '__eq') + state.lua.lua_setfield(state.address, metatableIndex, '__eq') - thread.pushValue((self: any, ...args: any[]) => { + state.pushValue((self: any, ...args: any[]) => { if (args[0] === self) { args.shift() } return self(...args) }) - thread.lua.lua_setfield(thread.address, metatableIndex, '__call') + state.lua.lua_setfield(state.address, metatableIndex, '__call') } // Pop the metatable from the stack. - thread.lua.lua_pop(thread.address, 1) + state.lua.lua_pop(state.address, 1) } public isType(_thread: Thread, _index: number, type: LuaType, name?: string): boolean { @@ -125,7 +118,7 @@ class ProxyTypeExtension extends TypeExtension { return thread.lua.getRef(referencePointer) } - public pushValue(thread: Thread, decoratedValue: Decoration): boolean { + public pushValue(thread: Thread, decoratedValue: Decoration): boolean { const { target, options } = decoratedValue if (options.as === undefined) { if (target === null || target === undefined) { @@ -160,10 +153,10 @@ class ProxyTypeExtension extends TypeExtension { } public close(): void { - this.thread.lua._emscripten.removeFunction(this.gcPointer) + this.state.lua._emscripten.removeFunction(this.gcPointer) } } -export default function createTypeExtension(thread: LuaState): TypeExtension { - return new ProxyTypeExtension(thread) +export default function createTypeExtension(state: LuaState): TypeExtension { + return new ProxyTypeExtension(state) } diff --git a/src/type-extensions/table.ts b/src/type-extensions/table.ts index e6e6bbf..2501735 100644 --- a/src/type-extensions/table.ts +++ b/src/type-extensions/table.ts @@ -1,14 +1,14 @@ import { Decoration } from '../decoration' import type LuaState from '../state' -import Thread from '../thread' +import type Thread from '../thread' import TypeExtension from '../type-extension' -import { LUA_REGISTRYINDEX, LuaType } from '../types' +import { LUA_REGISTRYINDEX, type LuaGetCache, type LuaPushCache, LuaType } from '../types' export type TableType = Record | any[] class TableTypeExtension extends TypeExtension { - public constructor(thread: LuaState) { - super(thread, 'js_table') + public constructor(state: LuaState) { + super(state, 'js_table') } public close(): void { @@ -19,12 +19,12 @@ class TableTypeExtension extends TypeExtension { return type === LuaType.Table } - public getValue(thread: Thread, index: number, userdata?: any): TableType { + public getValue(thread: Thread, index: number, cache?: LuaGetCache): TableType { // This is a map of Lua pointers to JS objects. - const seenMap: Map = userdata || new Map() + const seenMap: LuaGetCache = cache ?? new Map() const pointer = thread.lua.lua_topointer(thread.address, index) - let table = seenMap.get(pointer) + let table = seenMap.get(pointer) as TableType | undefined if (!table) { const keys = this.readTableKeys(thread, index) @@ -38,13 +38,13 @@ class TableTypeExtension extends TypeExtension { return table } - public pushValue(thread: Thread, { target }: Decoration, userdata?: Map): boolean { + public pushValue(thread: Thread, { target }: Decoration, cache?: LuaPushCache): boolean { if (typeof target !== 'object' || target === null) { return false } // This is a map of JS objects to luaL references. - const seenMap = userdata || new Map() + const seenMap: LuaPushCache = cache ?? new Map() const existingReference = seenMap.get(target) if (existingReference !== undefined) { thread.lua.lua_rawgeti(thread.address, LUA_REGISTRYINDEX, BigInt(existingReference)) @@ -82,7 +82,8 @@ class TableTypeExtension extends TypeExtension { } } } finally { - if (userdata === undefined) { + // Only the outermost push owns the anchors it created for the values below it. + if (cache === undefined) { for (const reference of seenMap.values()) { thread.lua.luaL_unref(thread.address, LUA_REGISTRYINDEX, reference) } @@ -107,7 +108,7 @@ class TableTypeExtension extends TypeExtension { return keys } - private readTableValues(thread: Thread, index: number, seenMap: Map, table: TableType): void { + private readTableValues(thread: Thread, index: number, seenMap: LuaGetCache, table: TableType): void { const isArray = Array.isArray(table) thread.lua.lua_pushnil(thread.address) @@ -126,6 +127,6 @@ class TableTypeExtension extends TypeExtension { } } -export default function createTypeExtension(thread: LuaState): TypeExtension { - return new TableTypeExtension(thread) +export default function createTypeExtension(state: LuaState): TypeExtension { + return new TableTypeExtension(state) } diff --git a/src/type-extensions/userdata.ts b/src/type-extensions/userdata.ts index ac9cd5a..3d83d16 100644 --- a/src/type-extensions/userdata.ts +++ b/src/type-extensions/userdata.ts @@ -1,38 +1,31 @@ import { Decoration } from '../decoration' import type LuaState from '../state' -import Thread from '../thread' +import type Thread from '../thread' import TypeExtension from '../type-extension' -import { LuaReturn, LuaAddress, LuaType } from '../types' +import { LuaType } from '../types' class UserdataTypeExtension extends TypeExtension { private readonly gcPointer: number - public constructor(thread: LuaState) { - super(thread, 'js_userdata') + public constructor(state: LuaState) { + super(state, 'js_userdata') - this.gcPointer = thread.lua._emscripten.addFunction((functionStateAddress: LuaAddress) => { - // Throws a lua error which does a jump if it does not match. - const userDataPointer = thread.lua.luaL_checkudata(functionStateAddress, 1, this.name) - const referencePointer = thread.lua._emscripten.getValue(userDataPointer, '*') - thread.lua.unref(referencePointer) + this.gcPointer = this.createGcFunction() - return LuaReturn.Ok - }, 'ii') - - if (thread.lua.luaL_newmetatable(thread.address, this.name)) { - const metatableIndex = thread.lua.lua_gettop(thread.address) + if (state.lua.luaL_newmetatable(state.address, this.name)) { + const metatableIndex = state.lua.lua_gettop(state.address) // Mark it as uneditable - thread.lua.lua_pushstring(thread.address, 'protected metatable') - thread.lua.lua_setfield(thread.address, metatableIndex, '__metatable') + state.lua.lua_pushstring(state.address, 'protected metatable') + state.lua.lua_setfield(state.address, metatableIndex, '__metatable') // Add the gc function - thread.lua.lua_pushcclosure(thread.address, this.gcPointer, 0) - thread.lua.lua_setfield(thread.address, metatableIndex, '__gc') + state.lua.lua_pushcclosure(state.address, this.gcPointer, 0) + state.lua.lua_setfield(state.address, metatableIndex, '__gc') } // Pop the metatable from the stack. - thread.lua.lua_pop(thread.address, 1) + state.lua.lua_pop(state.address, 1) } public isType(_thread: Thread, _index: number, type: LuaType, name?: string): boolean { @@ -45,7 +38,7 @@ class UserdataTypeExtension extends TypeExtension { return thread.lua.getRef(referencePointer) } - public pushValue(thread: Thread, decoratedValue: Decoration): boolean { + public pushValue(thread: Thread, decoratedValue: Decoration): boolean { if (decoratedValue.options.as !== 'userdata') { return false } @@ -54,10 +47,10 @@ class UserdataTypeExtension extends TypeExtension { } public close(): void { - this.thread.lua._emscripten.removeFunction(this.gcPointer) + this.state.lua._emscripten.removeFunction(this.gcPointer) } } -export default function createTypeExtension(thread: LuaState): TypeExtension { - return new UserdataTypeExtension(thread) +export default function createTypeExtension(state: LuaState): TypeExtension { + return new UserdataTypeExtension(state) } diff --git a/src/types.ts b/src/types.ts index 0985b9c..abccc7b 100755 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,7 @@ -/** A pointer to a `lua_State` inside the wasm heap. */ +/** + * An address in the wasm heap: a `lua_State`, or the storage behind a Lua value. Emscripten + * function pointers are table indices rather than addresses, so those stay plain numbers. + */ export type LuaAddress = number /** Receives diagnostics the library would otherwise have written to the console. */ @@ -61,16 +64,16 @@ export interface LuaMemoryOptions { * Installs a custom allocator so memory can be measured and capped. Without it `state.memory` * is undefined. */ - trace?: boolean + trace?: boolean | undefined /** Maximum bytes the state may allocate. Requires `trace`. */ - max?: number + max?: number | undefined } export interface LuaLimitOptions { /** Milliseconds a Lua function called from JS may run before being interrupted. */ - functionTimeout?: number + functionTimeout?: number | undefined /** Instructions a single run may execute before being interrupted. */ - maxInstructions?: number + maxInstructions?: number | undefined } export interface CreateStateOptions { @@ -78,30 +81,30 @@ export interface CreateStateOptions { * Which standard libraries to open. `true` opens all of them, `false` opens none (which * leaves the state without even `tostring`), or name them individually. */ - libs?: LuaLibName[] | boolean + libs?: LuaLibName[] | boolean | undefined /** * How plain JS objects and class instances cross into Lua. `'proxy'` keeps their identity and * exposes their members, `'copy'` marshals them into plain Lua tables. */ - objects?: 'proxy' | 'copy' + objects?: 'proxy' | 'copy' | undefined /** * Registers the JS `Error` to Lua error bridge. Defaults to true when `objects` is `'copy'`, * because the proxy already covers errors when it is enabled. */ - errors?: boolean + errors?: boolean | undefined /** Injects `Error`, `Promise` and `null` into the Lua globals. */ - inject?: boolean - memory?: LuaMemoryOptions - limits?: LuaLimitOptions + inject?: boolean | undefined + memory?: LuaMemoryOptions | undefined + limits?: LuaLimitOptions | undefined /** Where diagnostics go. Defaults to `console.warn`. */ - onWarn?: LuaWarnHandler + onWarn?: LuaWarnHandler | undefined } export interface LuaRunOptions { /** Milliseconds before the run is interrupted. */ - timeout?: number + timeout?: number | undefined /** Instructions the run may execute before being interrupted. */ - maxInstructions?: number + maxInstructions?: number | undefined /** * Interrupts the run when the signal aborts. * @@ -111,7 +114,7 @@ export interface LuaRunOptions { * the hook evaluates them on its own. And a run parked on a promise finishes awaiting that * promise before the abort is seen, rather than abandoning it mid-flight. */ - signal?: AbortSignal + signal?: AbortSignal | undefined } export interface LuaLoadOptions { @@ -120,19 +123,22 @@ export interface LuaLoadOptions { * bytecode from an untrusted source is a memory safety hole rather than a sandbox escape. * Only widen this for chunks you produced yourself. */ - mode?: LuaLoadMode + mode?: LuaLoadMode | undefined /** Chunk name used in error messages and tracebacks. */ - name?: string + name?: string | undefined } export type LuaDoOptions = LuaRunOptions & LuaLoadOptions -/** Deadline and budget enforced by the debug hook while a thread runs. */ +/** + * Deadline and budget enforced by the debug hook while a thread runs. Every field accepts an + * explicit undefined, which clears that one limit. + */ export interface LuaThreadLimits { /** Absolute timestamp, as returned by `Date.now()`. */ - deadline?: number - maxInstructions?: number - signal?: AbortSignal + deadline?: number | undefined + maxInstructions?: number | undefined + signal?: AbortSignal | undefined } export enum LuaReturn { @@ -150,6 +156,15 @@ export interface LuaResumeResult { resultCount: number } +/** + * Memo threaded through a recursive read, keyed by the address of the Lua value, so a cyclic table + * produces a cyclic JS object instead of recursing forever. + */ +export type LuaGetCache = Map + +/** The mirror of {@link LuaGetCache}, holding a registry reference to anchor each pushed value. */ +export type LuaPushCache = Map + export const PointerSize = 4 export const LUA_MULTRET = -1 @@ -198,7 +213,7 @@ export class LuaError extends Error { /** The value Lua actually raised, which is not always a string. */ public readonly luaValue: unknown - public constructor(code: LuaReturn, luaMessage: string, options: { traceback?: string; luaValue?: unknown } = {}) { + public constructor(code: LuaReturn, luaMessage: string, options: { traceback?: string | undefined; luaValue?: unknown } = {}) { super(luaMessage) this.code = code this.luaMessage = luaMessage diff --git a/src/utils.ts b/src/utils.ts index 1e6744e..3f10fc4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,12 @@ -export const isPromise = (target: any): target is Promise => { - return target && (Promise.resolve(target) === target || typeof target.then === 'function') +/** + * Any thenable, not just a native promise. Callers that need `catch`/`finally` rather than only + * `await` have to normalise with `Promise.resolve` first. + */ +export const isPromise = (target: unknown): target is PromiseLike => { + // A `Promise.resolve(target) === target` identity check would also answer this, but it + // allocates a promise for every value that turns out not to be one, and for a bare thenable it + // calls the user's `then` just to find out. + return typeof target === 'object' && target !== null && typeof (target as PromiseLike).then === 'function' } /** diff --git a/test/.oxlintrc.json b/test/.oxlintrc.json new file mode 100644 index 0000000..a2a6ace --- /dev/null +++ b/test/.oxlintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "no-unused-expressions": "off" + } +} diff --git a/test/errors.test.js b/test/errors.test.js index 2d9c9c6..dc247dc 100644 --- a/test/errors.test.js +++ b/test/errors.test.js @@ -49,7 +49,7 @@ describe('Unrepresentable values', () => { const thread = state.newThread() thread.lua.lua_newuserdatauv(thread.address, 4, 0) - expect(Number(thread.getPointer(-1))).to.be.greaterThan(0) + expect(thread.getPointer(-1)).to.be.greaterThan(0) }) }) diff --git a/test/state.test.js b/test/state.test.js index 74c1c2d..82bcf02 100644 --- a/test/state.test.js +++ b/test/state.test.js @@ -564,8 +564,8 @@ describe('State', () => { const state = await getState() const thread = state.newThread() - thread.setTimeout(Date.now() + 20) - thread.setTimeout(Date.now() + 20_000) + thread.setDeadline(Date.now() + 20) + thread.setDeadline(Date.now() + 20_000) thread.loadString('local x = 0 for i = 1, 20000000 do x = x + 1 end return x') expect((await thread.run(0))[0]).to.be.equal(20000000) @@ -576,11 +576,11 @@ describe('State', () => { const state = await getState() const thread = state.newThread() - thread.setTimeout(Date.now() + 10) - thread.setTimeout(undefined) + thread.setDeadline(Date.now() + 10) + thread.setDeadline(undefined) thread.loadString('local x = 0 for i = 1, 5000000 do x = x + 1 end return 7') - expect(thread.getTimeout()).to.be.undefined + expect(thread.getDeadline()).to.be.undefined expect((await thread.run(0))[0]).to.be.equal(7) }) @@ -1225,6 +1225,6 @@ describe('Synchronous runs', () => { state.doStringSync('return 1', { timeout: 1000 }) - expect(state.getTimeout()).to.be.undefined + expect(state.getDeadline()).to.be.undefined }) }) diff --git a/tsconfig.json b/tsconfig.json index 54b02dd..94f684f 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,8 +18,11 @@ "noUnusedLocals": true, "importHelpers": true, "strict": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, "resolveJsonModule": true }, - "include": ["src/**/*", "test/**/*", "bench/**/*"], + // test/ and bench/ are plain JS, so without allowJs they were only nominally covered. + "include": ["src/**/*"], "exclude": ["node_modules"] } From 574ee5462ee9dd22b1a0299f29207034de16acfe Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Tue, 28 Jul 2026 22:13:29 -0300 Subject: [PATCH 43/52] better browser support --- .prettierignore | 6 -- README.md | 162 +++++++++++++++++------------------ package.json | 16 +++- rolldown.config.ts | 41 +++++++-- src/module.ts | 195 ++++++++++++++++++++++++++---------------- src/utils.ts | 12 +-- test/browser.test.js | 154 ++++++++++++++++++++++++++++++--- test/bundling.test.js | 84 ++++++++++++++++++ utils/build-wasm.sh | 4 +- 9 files changed, 485 insertions(+), 189 deletions(-) delete mode 100644 .prettierignore create mode 100644 test/bundling.test.js diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 39d09df..0000000 --- a/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -dist/ -node_modules/ -.rollup.cache/ -package-lock.json -build/ -.eslintrc.json diff --git a/README.md b/README.md index f8f2543..d5d12ba 100644 --- a/README.md +++ b/README.md @@ -12,36 +12,42 @@ This package aims to provide a way to: ## API Usage -To initialize, create a new Lua state, register the standard library, set a global variable, execute a code and get a global variable: +Load the wasm module once, create a state on it, then set a global, run some Lua and read a global back: ```js -const { LuaFactory } = require('wasmoon') +import { LuaRuntime } from 'wasmoon' -// Initialize a new lua environment factory -// 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 -const factory = new LuaFactory() -// Create a standalone lua environment from the factory -const lua = await factory.createEngine() +// Loads the Lua wasm module. Every state created from it shares the filesystem and stdio. +const lua = await LuaRuntime.load() +// A standalone Lua state, with the standard library open +const state = lua.createState() try { // Set a JS function to be a global lua function - lua.global.set('sum', (x, y) => x + y) + state.set('sum', (x, y) => x + y) // Run a lua string - await lua.doString(` + await state.doString(` print(sum(10, 10)) function multiply(x, y) return x * y end `) // Get a global lua function as a JS function - const multiply = lua.global.get('multiply') + const multiply = state.get('multiply') console.log(multiply(10, 10)) } finally { - // Close the lua environment, so it can be freed - lua.global.close() + // Close the state, so it can be freed + state.close() } ``` +Loading the module is the expensive part, so keep one `LuaRuntime` around and create a state per +sandbox. `lua.close()` closes every state created from it, and both types support `using`: + +```js +await using lua = await LuaRuntime.load() +``` + ## CLI Usage 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). @@ -89,122 +95,114 @@ Wasmoon is **~10x faster** than fengari for pure Lua execution. If your use case ### Size -Fengari is smaller than wasmoon, which can improve the user experience if in web environments: +Fengari is smaller than wasmoon, which can improve the user experience if in web environments. +Both minified, and wasmoon counted as its JS plus `glue.wasm`: -| | wasmoon | fengari | -| ----------- | ------- | ------- | -| **plain** | 357kB | 211kB | -| **gzipped** | 123kB | 69kB | +| | wasmoon | fengari | +| ----------- | ---------------- | ------- | +| **plain** | 294kB (97 + 197) | 228kB | +| **gzipped** | 124kB (29 + 95) | 74kB | -## Fixing common errors on web environment +Almost all of wasmoon's weight is the wasm, which is a separate file: it is fetched in parallel +with your JS rather than parsed as part of it, and it caches on its own across releases of your app. -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. -Polyfilling these modules is not the right solution because they are not actually being used, you just have to ignore them: +## Web environment -### Webpack +Bundlers need no configuration. wasmoon ships as ESM and marks the two node builtins it touches so +that a bundler targeting the browser skips them instead of failing to resolve them, and every +supported bundler produces a working browser build out of the box. -Add the `resolve.fallback` snippet to your config: +### Where `glue.wasm` comes from -```js -module.exports = { - entry: './src/index.js', // Here is your entry file - resolve: { - fallback: { - path: false, - fs: false, - child_process: false, - crypto: false, - url: false, - module: false, - }, - }, -} -``` +The wasm is resolved next to the bundle, with `new URL('glue.wasm', import.meta.url)`. What that +means depends on your bundler: -### Rollup +| bundler | what happens | +| ------- | ------------------------------------------------------------------ | +| Vite | inlines the wasm into the bundle, nothing else to do | +| webpack | emits the wasm as an asset next to your output, nothing else to do | +| esbuild | does not handle the asset, see below | +| Rollup | does not handle the asset, see below | -With the package [rollup-plugin-ignore](https://www.npmjs.com/package/rollup-plugin-ignore), add this snippet to your config: +esbuild and Rollup leave nothing beside the bundle to fetch, so wasmoon falls back to unpkg with a +warning. That works, but it is a request to a third party pinned to wasmoon's version, so prefer +copying the wasm next to your output as part of the build, after which nothing else is needed: ```js -export default { - input: 'src/index.js', // Here is your entry file, - plugins: [ignore(['path', 'fs', 'child_process', 'crypto', 'url', 'module'])], -} +import { copyFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +await copyFile(fileURLToPath(import.meta.resolve('wasmoon/glue.wasm')), 'dist/glue.wasm') ``` -### Angular - -Add the section browser on `package.json`: - -```json -{ - "main": "src/index.js", - "browser": { - "child_process": false, - "fs": false, - "path": false, - "crypto": false, - "url": false, - "module": false - } -} +Or host it wherever you like and say where it is: + +```js +const lua = await LuaRuntime.load({ wasmFile: '/assets/glue.wasm' }) ``` -## How to build +With Vite and webpack you want neither, since they already handle the asset. Asking for it again +(`wasmoon/glue.wasm?url` and friends) makes them ship the wasm twice. -Firstly download the lua submodule and install the other Node.JS dependencies: +### A page opened from `file://` -```sh -git submodule update --init # download lua submodule -npm i # install dependencies +A page loaded over `file://` cannot fetch anything next to it, so the wasm has to come from +somewhere else and wasmoon falls back to unpkg. To keep such a page self contained, inline the wasm +and hand it over as a data URL: + +```js +const lua = await LuaRuntime.load({ wasmFile: 'data:application/wasm;base64,...' }) ``` -### Windows / Linux / MacOS (Docker way) +Note that Chromium also refuses to load ES modules over `file://`, so the page has to carry your +bundle inline rather than in a ` ` + // A module worker resolves the wasm the same way a page does, but with no `window` in scope. + const workerScript = ` +import LuaRuntime from './index.js' +self.onmessage = async () => { + try { + const lua = await LuaRuntime.load() + const state = lua.createState() + self.postMessage({ ok: await state.doString('return 2 + 5') }) + } catch (err) { + self.postMessage({ error: String(err) }) + } +} +` + const server = createServer(async (req, res) => { const url = new URL(req.url, `http://localhost`) @@ -36,6 +52,12 @@ window.__ready = true return } + if (url.pathname === '/worker.js') { + res.writeHead(200, { 'Content-Type': 'application/javascript' }) + res.end(workerScript) + return + } + const filePath = join(DIST_DIR, url.pathname) try { @@ -58,31 +80,54 @@ window.__ready = true } describe('Browser environment', () => { - let port, browser, context + let port, server, browser, context before(async function () { this.timeout(30_000) - ;({ port } = await startServer()) + ;({ server, port } = await startServer()) browser = await chromium.launch() context = await browser.newContext() }) - async function runInBrowser(code) { + after(async () => { + await browser?.close() + server?.close() + }) + + /** Every URL the page asked for, so a test can tell where the wasm was fetched from. */ + let requested = [] + + async function openPage() { const page = await context.newPage() + requested = [] + page.on('request', (req) => requested.push(req.url())) + const errors = [] page.on('pageerror', (err) => errors.push(err)) + page.assertNoErrors = () => { + if (errors.length > 0) { + throw errors[0] + } + } - try { - await page.goto(`http://127.0.0.1:${port}/`) - await page.waitForFunction(() => window.__ready === true, null, { timeout: 15_000 }) + await page.goto(`http://127.0.0.1:${port}/`) + await page.waitForFunction(() => window.__ready === true, null, { timeout: 15_000 }) + return page + } + + function expectNoExternalRequests() { + expect(requested.filter((url) => !url.startsWith(`http://127.0.0.1:${port}/`))).to.be.empty + } + async function runInBrowser(code) { + const page = await openPage() + + try { const result = await page.evaluate(async (c) => { return await window.__runTest(c) }, code) - if (errors.length > 0) { - throw errors[0] - } + page.assertNoErrors() return result } finally { await page.close() @@ -202,4 +247,91 @@ describe('Browser environment', () => { `) expect(result).to.be.equal(7) }) + + // Every test above passes an explicit wasmFile, which a consumer does not. Without one, the + // wrong default is a request to a CDN pinned to whatever version is in package.json. + describe('default wasm resolution', () => { + it('loads the wasm from the same origin when served over http', async function () { + this.timeout(30_000) + const result = await runInBrowser('await LuaRuntime.load(); return 1') + + expect(result).to.be.equal(1) + expect(requested).to.include(`http://127.0.0.1:${port}/glue.wasm`) + expectNoExternalRequests() + }) + + it('loads the wasm from the same origin inside a module worker', async function () { + this.timeout(30_000) + const page = await openPage() + + try { + const result = await page.evaluate(async () => { + const worker = new Worker('/worker.js', { type: 'module' }) + const message = new Promise((resolve) => worker.addEventListener('message', (e) => resolve(e.data))) + worker.postMessage('go') + return await message + }) + + page.assertNoErrors() + expect(result).to.be.eql({ ok: 7 }) + expectNoExternalRequests() + } finally { + await page.close() + } + }) + + it('falls back to the CDN for a page opened from file:', async function () { + this.timeout(60_000) + // Served locally rather than for real, to keep the suite offline and because the + // published wasm belongs to another version. + const { htmlFile, cleanup } = await writeInlinedPage() + const page = await context.newPage() + const cdnRequests = [] + + try { + await page.route('https://unpkg.com/**', async (route) => { + cdnRequests.push(route.request().url()) + await route.fulfill({ contentType: 'application/wasm', body: await readFile(join(DIST_DIR, 'glue.wasm')) }) + }) + + await page.goto(`file://${htmlFile}`) + await page.waitForFunction(() => window.__ready === true, null, { timeout: 15_000 }) + const result = await page.evaluate(async () => { + const lua = await window.__LuaRuntime.load() + return await lua.createState().doString('return 3 * 3') + }) + + expect(result).to.be.equal(9) + expect(cdnRequests).to.have.lengthOf(1) + expect(cdnRequests[0]).to.match(/^https:\/\/unpkg\.com\/wasmoon@[^/]+\/dist\/glue\.wasm$/) + } finally { + await page.close() + await cleanup() + } + }) + }) }) + +/** + * A single self contained HTML file, the only shape that works from `file:`: Chromium refuses to + * load an ES module over that protocol, so the bundle is inlined. It is already a single chunk, and + * its exported bindings are in scope of the inline module. + */ +async function writeInlinedPage() { + const tempDir = await mkdtemp(join(tmpdir(), 'wasmoon-file-')) + const bundle = await readFile(join(DIST_DIR, 'index.js'), 'utf8') + + const htmlFile = join(tempDir, 'page.html') + await writeFile( + htmlFile, + ` + +`, + ) + + return { htmlFile, cleanup: () => rm(tempDir, { recursive: true, force: true }) } +} diff --git a/test/bundling.test.js b/test/bundling.test.js new file mode 100644 index 0000000..5eec36e --- /dev/null +++ b/test/bundling.test.js @@ -0,0 +1,84 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { EventEmitter } from 'node:events' +import { expect } from 'chai' +import { rolldown } from 'rolldown' + +const DIST_DIR = fileURLToPath(new URL('../dist', import.meta.url)) +const WASM_FILE = join(DIST_DIR, 'glue.wasm') + +/** + * Consumers bundle us through a minifier, so anything in the published bundle that depends on a name + * surviving breaks in their build and not in ours. These run it against a minified copy. + */ +describe('Bundling', () => { + let tempDir + let minified + + before(async function () { + this.timeout(60_000) + tempDir = await mkdtemp(join(tmpdir(), 'wasmoon-bundling-')) + + const bundle = await rolldown({ + input: join(DIST_DIR, 'index.js'), + external: ['node:module', 'node:fs'], + }) + await bundle.write({ file: join(tempDir, 'index.js'), format: 'esm', minify: true }) + await bundle.close() + + minified = await import(join(tempDir, 'index.js')) + }) + + after(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }) + } + }) + + const getMinifiedState = async (config = {}) => { + const lua = await minified.LuaRuntime.load({ wasmFile: WASM_FILE }) + return lua.createState({ inject: true, ...config }) + } + + it('minifying the bundle renames the unwind classes', async () => { + // The premise of the tests below: a check by class name has nothing left to match on. + const code = await readFile(join(tempDir, 'index.js'), 'utf8') + expect(code).to.not.match(/class Emscripten/) + }) + + it('a yield across a C-call boundary still surfaces as an error when minified', async () => { + const state = await getMinifiedState() + const emitter = new EventEmitter() + state.set('yield', () => new Promise((resolve) => emitter.once('resolve', resolve))) + const resPromise = state.doString(` + local res = yield():next(function () + coroutine.yield() + return 15 + end) + print("res", res:await()) + `) + + emitter.emit('resolve') + await expect(resPromise).to.eventually.be.rejectedWith('Error: attempt to yield across a C-call boundary') + + expect(await state.doString('return 42')).to.equal(42) + }) + + it('a thread timeout inside a JS callback still surfaces as an error when minified', async () => { + const state = await getMinifiedState({ limits: { functionTimeout: 10 } }) + state.set('promise', Promise.resolve()) + const thread = state.newThread() + thread.loadString(` + promise:next(function () + while true do + -- nothing + end + end):await() + `) + await expect(thread.run(0, { timeout: 5 })).to.eventually.be.rejectedWith('thread timeout exceeded') + + expect(await state.doString('return 42')).to.equal(42) + }) +}) diff --git a/utils/build-wasm.sh b/utils/build-wasm.sh index c234f59..921f1d5 100755 --- a/utils/build-wasm.sh +++ b/utils/build-wasm.sh @@ -4,8 +4,8 @@ mkdir -p ../build LUA_SRC=$(ls ../lua/*.c | grep -v "luac.c" | grep -v "lua.c" | tr "\n" " ") -# Do not add --closure here: isEmscriptenUnwind identifies in-flight longjmps by class name, so -# mangling them would silently turn every unwind into a Lua error. +# Do not add --closure here: it renames properties, which would strip the brand the JS build puts on +# the glue's longjmp unwind classes (see rolldown.config.ts) and turn every unwind into a Lua error. extension="" if [ "$1" == "dev" ]; then From 3411ce5c6302c3401f9a4731acc5baa0a4fb3429 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Tue, 28 Jul 2026 22:48:12 -0300 Subject: [PATCH 44/52] tests ovehaul --- bin/wasmoon | 8 +- package.json | 1 + src/state.ts | 34 +++- src/type-extensions/error.ts | 4 + test/api.test.js | 213 +++++++++++++++++++ test/browser.test.js | 1 + test/cli.test.js | 192 ++++++++++++++++++ test/errors.test.js | 90 ++++++++- test/filesystem.test.js | 39 +++- test/initialization.test.js | 23 ++- test/limits.test.js | 10 +- test/node.test.js | 382 ----------------------------------- test/nodefs.test.js | 136 ++++++++----- test/promises.test.js | 36 ++-- test/state.test.js | 186 ++++++++--------- test/stdio.test.js | 112 ++++++++++ test/utils.js | 7 +- 17 files changed, 893 insertions(+), 581 deletions(-) create mode 100644 test/api.test.js create mode 100644 test/cli.test.js delete mode 100644 test/node.test.js create mode 100644 test/stdio.test.js diff --git a/bin/wasmoon b/bin/wasmoon index 3479141..b80cf70 100755 --- a/bin/wasmoon +++ b/bin/wasmoon @@ -230,6 +230,10 @@ if (warnings) { lua.module.lua_warning(state.address, '@on', 0) } +// Decorated as a value so `#arg` and `ipairs(arg)` behave like the standalone interpreter, and +// set before -l and -e run, as it does, so those can read it. +state.set('arg', decorate(scriptArgs, { as: 'value' })) + for (const module of includeModules) { let [global, mod] = module.split('=') if (!mod) { @@ -243,10 +247,6 @@ for (const snippet of executeSnippets) { await state.doString(snippet) } -// A real Lua table rather than a JS proxy, so `#arg` and `ipairs(arg)` behave like the -// standalone interpreter. -state.set('arg', decorate(scriptArgs, { as: 'value' })) - const isTTY = process.stdin.isTTY const hasProgram = Boolean(scriptFile) || executeSnippets.length > 0 diff --git a/package.json b/package.json index c7e0849..f416ecb 100755 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "build:wasm": "node utils/build-wasm", "start": "rolldown -w -c", "bench": "npm run build && node bench/index.js", + "pretest": "npm run build", "test": "mocha --parallel --exit --require ./test/boot.js test/*.test.js", "luatests": "node --experimental-import-meta-resolve test/luatests.js", "build": "rm -rf dist && rolldown -c && tsc -d --emitDeclarationOnly --rootDir src --declarationDir dist", diff --git a/src/state.ts b/src/state.ts index c7abc93..a55c6d9 100755 --- a/src/state.ts +++ b/src/state.ts @@ -30,6 +30,23 @@ export interface LuaMemory { max: number | undefined } +/** + * A more specific handler has to be consulted before a more general one. The values themselves + * cannot be renumbered: they are the scale {@link LuaState.registerTypeExtension} exposes, so + * anything a user registered against them would move too. + */ +const BUILT_IN_PRIORITY = { + table: 0, + function: 0, + promise: 1, + proxy: 3, + /** Beats proxy, which would otherwise claim every Error before this one is consulted. */ + error: 3.5, + /** Lets a custom userdata be exposed without its methods coming along. */ + userdata: 4, + null: 5, +} as const + interface CreatedState { address: LuaAddress memory?: LuaMemory @@ -117,31 +134,28 @@ export default class LuaState extends Thread { } // Generic handlers - These may be required to be registered for additional types. - this.registerTypeExtension(0, createTableType(this)) - this.registerTypeExtension(0, createFunctionType(this, limits?.functionTimeout)) + this.registerTypeExtension(BUILT_IN_PRIORITY.table, createTableType(this)) + this.registerTypeExtension(BUILT_IN_PRIORITY.function, createFunctionType(this, limits?.functionTimeout)) // Contains the :await functionality. - this.registerTypeExtension(1, createPromiseType(this, inject)) + this.registerTypeExtension(BUILT_IN_PRIORITY.promise, createPromiseType(this, inject)) if (inject) { - // Should be higher priority than table since that catches generic objects along - // with userdata so it doesn't end up a userdata type. - this.registerTypeExtension(5, createNullType(this)) + this.registerTypeExtension(BUILT_IN_PRIORITY.null, createNullType(this)) } if (errors) { - this.registerTypeExtension(1, createErrorType(this, inject)) + this.registerTypeExtension(BUILT_IN_PRIORITY.error, createErrorType(this, inject)) } if (objects === 'proxy') { // This extension only really overrides tables and arrays. // When a function is looked up in one of it's tables it's bound and then // handled by the function type extension. - this.registerTypeExtension(3, createProxyType(this)) + this.registerTypeExtension(BUILT_IN_PRIORITY.proxy, createProxyType(this)) } - // Higher priority than proxied objects to allow custom user data without exposing methods. - this.registerTypeExtension(4, createUserdataType(this)) + this.registerTypeExtension(BUILT_IN_PRIORITY.userdata, createUserdataType(this)) const libraryMask = resolveLibraryMask(libs) if (libraryMask !== 0) { diff --git a/src/type-extensions/error.ts b/src/type-extensions/error.ts index d376312..770ea15 100644 --- a/src/type-extensions/error.ts +++ b/src/type-extensions/error.ts @@ -60,6 +60,10 @@ class ErrorTypeExtension extends TypeExtension { if (!(decoration.target instanceof Error)) { return false } + // An explicit `as` names a representation this extension does not provide. + if (decoration.options.as !== undefined) { + return false + } return super.pushValue(thread, decoration) } diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 0000000..6d7ba51 --- /dev/null +++ b/test/api.test.js @@ -0,0 +1,213 @@ +import { expect } from 'chai' +import { LuaMultiReturn, LuaRawResult, LuaTypeExtension, decorate } from '../dist/index.js' +import { getLua, getState } from './utils.js' + +describe('MultiReturn', () => { + it('should push several lua values from one JS function', async () => { + using state = await getState() + state.set('divide', (a, b) => LuaMultiReturn.of(Math.floor(a / b), a % b)) + + const result = await state.doString('local q, r = divide(7, 2) return ("%d,%d"):format(q, r)') + + expect(result).to.be.equal('3,1') + }) + + it('should report its length to lua', async () => { + using state = await getState() + state.set('three', () => LuaMultiReturn.of('a', 'b', 'c')) + + expect(await state.doString('return select("#", three())')).to.be.equal(3) + }) + + it('should be distinguishable from a plain array, which stays one table', async () => { + using state = await getState({ objects: 'copy' }) + state.set('plain', () => ['a', 'b', 'c']) + + expect(await state.doString('return select("#", plain())')).to.be.equal(1) + expect(await state.doString('return type(plain())')).to.be.equal('table') + }) + + it('should push nothing for an empty one', async () => { + using state = await getState() + state.set('none', () => LuaMultiReturn.of()) + + expect(await state.doString('return select("#", none())')).to.be.equal(0) + }) +}) + +describe('RawResult', () => { + it('should hand back values the function pushed itself', async () => { + using state = await getState() + state.set( + 'raw', + decorate( + (thread) => { + thread.pushValue('a') + thread.pushValue('b') + return new LuaRawResult(2) + }, + { receiveThread: true }, + ), + ) + + expect(await state.doString('local x, y = raw() return x .. y')).to.be.equal('ab') + expect(await state.doString('return select("#", raw())')).to.be.equal(2) + }) +}) + +describe('Decoration options', () => { + it('self should bind the receiver and drop it from the arguments', async () => { + using state = await getState() + const counter = { + n: 41, + bump(by) { + this.n += by ?? 1 + return this.n + }, + } + state.set('bump', decorate(counter.bump, { self: counter })) + + expect(await state.doString('return bump()')).to.be.equal(42) + expect(await state.doString('return bump(8)')).to.be.equal(50) + expect(counter.n).to.be.equal(50) + }) + + it('receiveThread should pass the calling thread as the first argument', async () => { + using state = await getState() + state.set( + 'grab', + decorate((thread, ...args) => `${typeof thread.address}:${args.join(',')}`, { receiveThread: true }), + ) + + expect(await state.doString('return grab("a", "b")')).to.be.equal('number:a,b') + }) + + it('receiveArgsQuantity should pass the count instead of the arguments', async () => { + using state = await getState() + state.set( + 'count', + decorate((quantity) => quantity, { receiveArgsQuantity: true }), + ) + + expect(await state.doString('return count(1, 2, 3)')).to.be.equal(3) + expect(await state.doString('return count()')).to.be.equal(0) + }) +}) + +describe('Custom type extensions', () => { + class Point { + constructor(x) { + this.x = x + } + } + + // A dedicated metatable name is the thing no built in provides, and what proves the custom + // extension ran rather than the userdata one. + class PointExtension extends LuaTypeExtension { + constructor(state) { + super(state, 'js_point') + this.gcPointer = this.createGcFunction() + + if (state.lua.luaL_newmetatable(state.address, this.name)) { + const metatableIndex = state.lua.lua_gettop(state.address) + state.lua.lua_pushcclosure(state.address, this.gcPointer, 0) + state.lua.lua_setfield(state.address, metatableIndex, '__gc') + state.pushValue((point) => `Point(${point.x})`) + state.lua.lua_setfield(state.address, metatableIndex, '__tostring') + } + state.lua.lua_pop(state.address, 1) + } + + close() { + this.state.lua._emscripten.removeFunction(this.gcPointer) + } + + pushValue(thread, decoration) { + return decoration.target instanceof Point ? super.pushValue(thread, decoration) : false + } + } + + it('should take priority over the built in handling and round trip', async () => { + using state = await getState() + state.registerTypeExtension(6, new PointExtension(state)) + const point = new Point(9) + state.set('pt', point) + + expect(await state.doString('return type(pt)')).to.be.equal('userdata') + expect(await state.doString('return tostring(pt)')).to.be.equal('Point(9)') + expect(state.get('pt')).to.be.equal(point) + }) + + it('should leave values it refuses to the built in handling', async () => { + using state = await getState() + state.registerTypeExtension(6, new PointExtension(state)) + state.set('other', { x: 1 }) + + expect(await state.doString('return other.x')).to.be.equal(1) + }) +}) + +describe('Thread lifecycle', () => { + it('resetThread should let a finished thread be loaded again', async () => { + using state = await getState() + const thread = state.newThread() + thread.loadString('return 1') + expect(await thread.run()).to.be.eql([1]) + + thread.resetThread() + thread.loadString('return 42') + + expect(await thread.run()).to.be.eql([42]) + }) + + it('resetThread should surface the error that stopped the thread', async () => { + // lua_resetthread reports the status the thread died with. + using state = await getState() + const thread = state.newThread() + thread.loadString('error("boom")') + await expect(thread.run()).to.eventually.be.rejectedWith('boom') + + expect(() => thread.resetThread()).to.throw('boom') + }) + + it('loadString name should set the chunk name used in errors', async () => { + using state = await getState() + const thread = state.newThread() + thread.loadString('error("named")', { name: '@mychunk.lua' }) + + await expect(thread.run()).to.eventually.be.rejectedWith('mychunk.lua:1: named') + }) + + it('onClose should fire once when the state closes', async () => { + const lua = await getLua() + const state = lua.createState() + let closed = 0 + state.onClose(() => closed++) + + state.close() + state.close() + + expect(closed).to.be.equal(1) + }) + + it('indexToString should render a value the way lua would', async () => { + using state = await getState() + state.pushValue(12) + state.pushValue('hi') + + expect(state.indexToString(-2)).to.be.equal('12') + expect(state.indexToString(-1)).to.be.equal('hi') + + state.pop(2) + }) + + it('indexToString should call __tostring when there is one', async () => { + using state = await getState() + state.set('thing', decorate({}, { metatable: { __tostring: () => 'rendered' } })) + state.lua.lua_getglobal(state.address, 'thing') + + expect(state.indexToString(-1)).to.be.equal('rendered') + + state.pop() + }) +}) diff --git a/test/browser.test.js b/test/browser.test.js index 9bda74e..aa28806 100644 --- a/test/browser.test.js +++ b/test/browser.test.js @@ -274,6 +274,7 @@ describe('Browser environment', () => { page.assertNoErrors() expect(result).to.be.eql({ ok: 7 }) + expect(requested).to.include(`http://127.0.0.1:${port}/glue.wasm`) expectNoExternalRequests() } finally { await page.close() diff --git a/test/cli.test.js b/test/cli.test.js new file mode 100644 index 0000000..6b491f6 --- /dev/null +++ b/test/cli.test.js @@ -0,0 +1,192 @@ +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { expect } from 'chai' +import pkg from '../package.json' with { type: 'json' } + +// Absolute, so a test can hand the child a cwd of its own. +const CLI = fileURLToPath(new URL('../bin/wasmoon', import.meta.url)) + +// stdin defaults to closed, matching a piped invocation with nothing to read. Never a TTY here, +// so the REPL branch and `-i` stay out of reach. +const runCli = (args, { input, ...options } = {}) => { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [CLI, ...args], { + stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + ...options, + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => (stdout += chunk)) + child.stderr.on('data', (chunk) => (stderr += chunk)) + child.on('error', reject) + child.on('close', (code) => resolve({ code, stdout, stderr })) + if (input !== undefined) { + child.stdin.end(input) + } + }) +} + +describe('CLI', function () { + this.timeout(30_000) + + let tempDir + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'wasmoon-cli-')) + }) + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + + describe('scripts and snippets', () => { + // Covers the single snippet case too, which is why there is no separate test for it. + it('should run several -e snippets in order', async () => { + const { code, stdout, stderr } = await runCli(['-e', 'print("first")', '-e', 'print("second")']) + + expect(stderr).to.be.empty + expect(code).to.be.equal(0) + expect(stdout).to.be.equal('first\nsecond\n') + }) + + it('should run a script file', async () => { + const script = join(tempDir, 'hello.lua') + writeFileSync(script, 'print("from file")') + + const { code, stdout } = await runCli([script]) + + expect(code).to.be.equal(0) + expect(stdout).to.be.equal('from file\n') + }) + + it('should run a script piped to stdin', async () => { + const { code, stdout } = await runCli([], { input: 'print("from bare stdin")\n' }) + + expect(code).to.be.equal(0) + expect(stdout).to.be.equal('from bare stdin\n') + }) + + it('should run a script piped to stdin when given -', async () => { + const { code, stdout } = await runCli(['-'], { input: 'print("from dash")\n' }) + + expect(code).to.be.equal(0) + expect(stdout).to.be.equal('from dash\n') + }) + }) + + describe('arg table', () => { + it('should expose arg as a lua table to a script', async () => { + const script = join(tempDir, 'args.lua') + writeFileSync(script, 'print(type(arg), #arg, arg[1], arg[2], table.concat(arg, "+"))') + + const { code, stdout, stderr } = await runCli([script, 'first', 'second']) + + expect(stderr).to.be.empty + expect(code).to.be.equal(0) + expect(stdout.trim()).to.be.equal('table\t2\tfirst\tsecond\tfirst+second') + }) + + // arg used to be built after the -e loop ran, so a snippet saw a nil global. + it('should expose arg to a -e snippet', async () => { + const { code, stdout, stderr } = await runCli(['-e', 'print(type(arg), #arg)']) + + expect(stderr).to.be.empty + expect(code).to.be.equal(0) + expect(stdout.trim()).to.be.equal('table\t0') + }) + + it('should pass arguments after -- through to arg', async () => { + const { code, stdout, stderr } = await runCli(['-e', 'print(arg[1])', '--', '--notaflag']) + + expect(stderr).to.be.empty + expect(code).to.be.equal(0) + expect(stdout.trim()).to.be.equal('--notaflag') + }) + }) + + describe('options', () => { + it('-v should report both versions', async () => { + const { code, stdout } = await runCli(['-v']) + + expect(code).to.be.equal(0) + expect(stdout.trim()).to.match(/^wasmoon \S+ \(Lua [\d.]+\)$/) + expect(stdout).to.include(`wasmoon ${pkg.version} `) + }) + + it('-l should require a module into a global of the same name', async () => { + writeFileSync(join(tempDir, 'mymod.lua'), 'return { v = 5 }') + + const { code, stdout } = await runCli(['-l', 'mymod', '-e', 'print(mymod.v)'], { cwd: tempDir }) + + expect(code).to.be.equal(0) + expect(stdout.trim()).to.be.equal('5') + }) + + it('-l g=mod should require a module into a renamed global', async () => { + writeFileSync(join(tempDir, 'mymod.lua'), 'return { v = 5 }') + + const { code, stdout } = await runCli(['-l', 'g=mymod', '-e', 'print(g.v, mymod)'], { cwd: tempDir }) + + expect(code).to.be.equal(0) + expect(stdout.trim()).to.be.equal('5\tnil') + }) + + it('-E should hide the host environment', async () => { + const env = { ...process.env, WASMOON_CLI_TEST: 'secret' } + + const [withEnv, without] = await Promise.all([ + runCli(['-e', 'print(os.getenv("WASMOON_CLI_TEST"))'], { env }), + runCli(['-E', '-e', 'print(os.getenv("WASMOON_CLI_TEST"))'], { env }), + ]) + + expect(withEnv.stdout.trim()).to.be.equal('secret') + expect(without.stdout.trim()).to.be.equal('nil') + }) + + it('an unrecognized option should print usage and fail', async () => { + const { code, stdout } = await runCli(['-Z']) + + expect(code).to.be.equal(1) + expect(stdout).to.include(`unrecognized option: '-Z'`) + expect(stdout).to.include('usage: wasmoon') + }) + + it('a missing argument after -e should fail', async () => { + const { code, stderr } = await runCli(['-e']) + + expect(code).to.be.equal(1) + expect(stderr).to.include('Missing argument after -e') + }) + }) + + describe('stdin', () => { + it('should read piped input from within a script', async () => { + const { code, stdout, stderr } = await runCli(['-e', 'print(io.read("l")) print(io.read("l")) print(io.read("l") == nil)'], { + input: 'olá mundo\nsegunda linha\n', + }) + + expect(stderr).to.be.empty + expect(code).to.be.equal(0) + expect(stdout).to.be.equal('olá mundo\nsegunda linha\ntrue\n') + }) + + it('should not add a line break to input that does not end with one', async () => { + const { code, stdout, stderr } = await runCli(['-e', 'print(#io.read("a"))'], { input: 'abc' }) + + expect(stderr).to.be.empty + expect(code).to.be.equal(0) + expect(stdout).to.be.equal('3\n') + }) + + it('should not run piped input as a script when -e is given', async () => { + const { code, stdout, stderr } = await runCli(['-e', 'print("from -e")'], { input: 'this is not lua code\n' }) + + expect(stderr).to.be.empty + expect(code).to.be.equal(0) + expect(stdout).to.be.equal('from -e\n') + }) + }) +}) diff --git a/test/errors.test.js b/test/errors.test.js index dc247dc..f82b69c 100644 --- a/test/errors.test.js +++ b/test/errors.test.js @@ -1,10 +1,10 @@ -import { LuaRuntime, LuaError } from '../dist/index.js' +import { LuaRuntime, LuaError, decorate } from '../dist/index.js' import { expect } from 'chai' import { getState } from './utils.js' describe('LuaError', () => { it('keeps the raised value, message and traceback apart', async () => { - const state = await getState() + using state = await getState() try { await state.doString(`error({ code = 7 })`) @@ -19,7 +19,7 @@ describe('LuaError', () => { }) it('a JS error thrown through Lua keeps its own stack', async () => { - const state = await getState() + using state = await getState() const thrown = new Error('from js') state.set('boom', () => { throw thrown @@ -35,9 +35,89 @@ describe('LuaError', () => { }) }) +describe('errors option', () => { + const setThrow = (state) => + state.set('boom', () => { + throw new Error('kaboom') + }) + const catchIt = (expression) => `local ok, err = pcall(boom) return ${expression}` + + // The "Error: " prefix is the tell: js_error renders the bare message, the proxy layer + // leaves tostring to Error.prototype. + const matrix = [ + ['off by default', undefined, 'Error: kaboom'], + ['on when asked for', { errors: true }, 'kaboom'], + // errors used to sit below proxy, so opting in while leaving objects alone did nothing. + ['on when asked for even though objects is proxy', { objects: 'proxy', errors: true }, 'kaboom'], + ['on by default when objects is copy', { objects: 'copy' }, 'kaboom'], + ] + + for (const [name, options, expected] of matrix) { + it(`should be ${name}`, async () => { + using state = await getState(options) + setThrow(state) + + expect(await state.doString(catchIt('tostring(err)'))).to.be.equal(expected) + }) + } + + it('should leave the real properties reachable when off', async () => { + using state = await getState() + setThrow(state) + + expect(await state.doString(catchIt('type(err.stack)'))).to.be.equal('string') + }) + + it('should expose the message when on', async () => { + using state = await getState({ errors: true }) + setThrow(state) + + expect(await state.doString(catchIt('err.message'))).to.be.equal('kaboom') + }) + + it('should be overridable to off when objects is copy', async () => { + using state = await getState({ objects: 'copy', errors: false }) + setThrow(state) + + expect(await state.doString(catchIt('type(err)'))).to.be.equal('table') + }) + + // Claiming every Error on type alone would swallow these, the way proxy used to swallow it. + describe('should defer to an explicit decoration', () => { + const cases = [ + ['userdata', 'userdata', 'js_userdata'], + ['proxy', 'userdata', 'Error: kaboom'], + ['value', 'table', 'table:'], + ] + + for (const [as, luaType, rendering] of cases) { + it(`as: '${as}'`, async () => { + using state = await getState({ errors: true }) + state.set('decorated', decorate(new Error('kaboom'), { as })) + + expect(await state.doString('return type(decorated)')).to.be.equal(luaType) + expect(await state.doString('return tostring(decorated)')).to.include(rendering) + }) + } + }) + + it('should expose an Error constructor to lua when injecting', async () => { + using state = await getState({ errors: true }) + + expect(await state.doString('return tostring(Error.create("made in lua"))')).to.be.equal('made in lua') + expect(await state.doString('return Error.create("made in lua").message')).to.be.equal('made in lua') + }) + + it('should not expose the Error constructor when the extension is off', async () => { + using state = await getState() + + expect(await state.doString('return type(Error)')).to.be.equal('nil') + }) +}) + describe('Unrepresentable values', () => { it('getValue throws instead of returning an opaque handle', async () => { - const state = await getState() + using state = await getState() const thread = state.newThread() thread.lua.lua_newuserdatauv(thread.address, 4, 0) @@ -45,7 +125,7 @@ describe('Unrepresentable values', () => { }) it('the address is still reachable through getPointer', async () => { - const state = await getState() + using state = await getState() const thread = state.newThread() thread.lua.lua_newuserdatauv(thread.address, 4, 0) diff --git a/test/filesystem.test.js b/test/filesystem.test.js index 09a5f16..3846d72 100644 --- a/test/filesystem.test.js +++ b/test/filesystem.test.js @@ -33,9 +33,9 @@ describe('Filesystem', () => { }) it('require a file which is not mounted should throw', async () => { - const state = await getState() + using state = await getState() - await expect(state.doString('require("nothing")')).to.eventually.be.rejected + await expect(state.doString('require("nothing")')).to.eventually.be.rejectedWith(/module 'nothing' not found/) }) it('mount a file and run it should succeed', async () => { @@ -49,9 +49,40 @@ describe('Filesystem', () => { }) it('run a file which is not mounted should throw', async () => { - const state = await getState() + using state = await getState() - await expect(state.doFile('init.lua')).to.eventually.be.rejected + await expect(state.doFile('init.lua')).to.eventually.be.rejectedWith(/cannot open init\.lua/) + }) + + it('mount a file with binary content should succeed', async () => { + const lua = await getLua() + using state = lua.createState() + + lua.mountFile('binary.lua', new Uint8Array([114, 101, 116, 117, 114, 110, 32, 52, 50])) // "return 42" + + expect(await state.doString('return dofile("binary.lua")')).to.be.equal(42) + }) + + it('doFileSync should run a mounted file synchronously', async () => { + const lua = await getLua() + lua.mountFile('sync.lua', 'return 5 * 5') + using state = lua.createState() + + expect(state.doFileSync('sync.lua')).to.be.equal(25) + }) + + it('filesystem should expose the shared emscripten FS', async () => { + const lua = await getLua() + lua.mountFile('fs-probe.lua', 'return 1') + + expect(lua.filesystem.analyzePath('/fs-probe.lua').exists).to.be.true + }) + + it('path should expose the emscripten path helpers', async () => { + const lua = await getLua() + + expect(lua.path.dirname('/a/b/c.lua')).to.be.equal('/a/b') + expect(lua.path.basename('/a/b/c.lua')).to.be.equal('c.lua') }) it('mount a file with a large content should succeed', async () => { diff --git a/test/initialization.test.js b/test/initialization.test.js index ab8f214..4aca780 100644 --- a/test/initialization.test.js +++ b/test/initialization.test.js @@ -4,25 +4,36 @@ import { expect } from 'chai' describe('Initialization', () => { it('create state should succeed', async () => { const lua = await LuaRuntime.load() - lua.createState() + using state = lua.createState() + + expect(state.isClosed()).to.be.false + expect(state.address).to.be.greaterThan(0) }) - it('create multiple states should succeed', async () => { + it('create multiple states should keep their globals independent', async () => { const lua = await LuaRuntime.load() - const state1 = lua.createState() - const state2 = lua.createState() + using state1 = lua.createState() + using state2 = lua.createState() + + await state1.doString('x = 10') + await state2.doString('x = 20') expect(state1.address).to.not.be.equal(state2.address) + expect(await state1.doString('return x')).to.be.equal(10) + expect(await state2.doString('return x')).to.be.equal(20) }) - it('create state with options should succeed', async () => { + it('create state with options should apply them', async () => { const lua = await LuaRuntime.load() - lua.createState({ + using state = lua.createState({ objects: 'proxy', inject: true, libs: true, memory: { trace: true }, }) + + expect(state.memory.used).to.be.greaterThan(0) + expect(await state.doString('return type(null)')).to.be.equal('userdata') }) it('create with environment variables should succeed', async () => { diff --git a/test/limits.test.js b/test/limits.test.js index 6a9323f..0fb0d32 100644 --- a/test/limits.test.js +++ b/test/limits.test.js @@ -7,28 +7,28 @@ describe('Run limits', () => { it('timeout interrupts a tight loop', async function () { this.timeout(20_000) - const state = await getState() + using state = await getState() await expect(state.doString(busyLoop, { timeout: 20 })).to.eventually.be.rejectedWith(LuaTimeoutError) }) it('maxInstructions interrupts a tight loop', async function () { this.timeout(20_000) - const state = await getState() + using state = await getState() await expect(state.doString(busyLoop, { maxInstructions: 5_000 })).to.eventually.be.rejectedWith(LuaInstructionLimitError) }) it('an already aborted signal stops the run', async function () { this.timeout(20_000) - const state = await getState() + using state = await getState() await expect(state.doString(busyLoop, { signal: AbortSignal.abort() })).to.eventually.be.rejectedWith(LuaAbortError) }) it('a signal aborted while parked on a promise stops the run', async function () { this.timeout(20_000) - const state = await getState() + using state = await getState() state.set('sleep', (ms) => new Promise((resolve) => setTimeout(resolve, ms))) const controller = new AbortController() setTimeout(() => controller.abort(), 5) @@ -65,7 +65,7 @@ describe('Run limits', () => { }) it('limits are restored after a scoped run', async () => { - const state = await getState() + using state = await getState() state.setLimits({ maxInstructions: 999 }) await state.doString('return 1', { maxInstructions: 10_000 }) diff --git a/test/node.test.js b/test/node.test.js deleted file mode 100644 index c581084..0000000 --- a/test/node.test.js +++ /dev/null @@ -1,382 +0,0 @@ -import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { spawn } from 'node:child_process' -import { expect } from 'chai' -import { LuaRuntime } from '../dist/index.js' - -// stdin is closed by default, as it would be for a redirected or piped invocation with nothing -// to read. -const runCli = (args, input) => { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, ['bin/wasmoon', ...args], { - stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - child.stdout.on('data', (chunk) => (stdout += chunk)) - child.stderr.on('data', (chunk) => (stderr += chunk)) - child.on('error', reject) - child.on('close', (code) => resolve({ code, stdout, stderr })) - if (input !== undefined) { - child.stdin.end(input) - } - }) -} - -// Collects everything the given script writes to the chosen stream. -const collectOutput = async (stream, script, opts = {}) => { - const output = [] - const lua = await LuaRuntime.load({ ...opts, [stream]: (content) => output.push(content) }) - await lua.createState().doString(script) - return output -} - -// Feeds one chunk per read, and then the empty string that stands for EOF. -const stdinFrom = (chunks) => { - let index = 0 - return () => chunks[index++] ?? '' -} - -describe('Node environment', () => { - it('load Lua engine in node should succeed', async () => { - const lua = await LuaRuntime.load() - const state = lua.createState() - const result = await state.doString('return 1 + 1') - expect(result).to.be.equal(2) - }) - - it('access environment variables should succeed', async () => { - const env = { MY_TEST_VAR: 'hello_from_node' } - const lua = await LuaRuntime.load({ env }) - const state = lua.createState() - const result = await state.doString('return os.getenv("MY_TEST_VAR")') - expect(result).to.be.equal('hello_from_node') - }) - - it('custom stdout should succeed', async () => { - const output = await collectOutput('stdout', 'print("hello from node")') - expect(output.join('')).to.include('hello from node') - }) - - it('custom stderr should succeed', async () => { - const errors = await collectOutput('stderr', 'io.stderr:write("error output\\n")') - expect(errors.join('')).to.include('error output') - }) - - it('mount file and require in node should succeed', async () => { - const lua = await LuaRuntime.load() - lua.mountFile('nodetest.lua', 'return { value = 99 }') - const state = lua.createState({ inject: true }) - const result = await state.doString('local m = require("nodetest"); return m.value') - expect(result).to.be.equal(99) - }) - - it('mount file with binary content should succeed', async () => { - const lua = await LuaRuntime.load() - const content = new Uint8Array([114, 101, 116, 117, 114, 110, 32, 52, 50]) // "return 42" - lua.mountFile('binary.lua', content) - const state = lua.createState() - const result = await state.doString('return dofile("binary.lua")') - expect(result).to.be.equal(42) - }) - - it('mount file in nested directories should succeed', async () => { - const lua = await LuaRuntime.load() - lua.mountFile('a/b/c/deep.lua', 'return "deep"') - const state = lua.createState() - const result = await state.doString('return require("a/b/c/deep")') - expect(result).to.be.equal('deep') - }) - - it('create multiple independent states should succeed', async () => { - const lua = await LuaRuntime.load() - const state1 = lua.createState() - const state2 = lua.createState() - - await state1.doString('x = 10') - await state2.doString('x = 20') - - const val1 = await state1.doString('return x') - const val2 = await state2.doString('return x') - - expect(val1).to.be.equal(10) - expect(val2).to.be.equal(20) - }) - - it('pass complex JS objects to Lua should succeed', async () => { - const state = (await LuaRuntime.load()).createState({ inject: true }) - state.set('data', { - name: 'test', - nested: { value: 42 }, - items: [1, 2, 3], - }) - const result = await state.doString('return data.nested.value') - expect(result).to.be.equal(42) - }) - - it('call JS async functions from Lua should succeed', async () => { - const state = (await LuaRuntime.load()).createState({ inject: true }) - state.set('fetchData', async () => { - return 'async result' - }) - const result = await state.doString('return fetchData():await()') - expect(result).to.be.equal('async result') - }) - - it('use NODEFS to read host files should succeed', async () => { - const tempDir = join(tmpdir(), `wasmoon-test-${Date.now()}`) - mkdirSync(tempDir, { recursive: true }) - const testFile = join(tempDir, 'test.txt') - writeFileSync(testFile, 'hello from host') - - try { - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await state.doString(` - local f = io.open("${testFile.replace(/\\/g, '/')}", "r") - local content = f:read("*a") - f:close() - return content - `) - expect(result).to.be.equal('hello from host') - } finally { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('use NODEFS to write and read back files should succeed', async () => { - const tempDir = join(tmpdir(), `wasmoon-test-${Date.now()}`) - mkdirSync(tempDir, { recursive: true }) - const testFile = join(tempDir, 'output.txt') - - try { - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - await state.doString(` - local f = io.open("${testFile.replace(/\\/g, '/')}", "w") - f:write("written from lua") - f:close() - `) - const content = readFileSync(testFile, 'utf8') - expect(content).to.be.equal('written from lua') - } finally { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('NODEFS cwd should match process.cwd', async () => { - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - // Write a temp file in the CWD and verify it can be read - const markerFile = join(process.cwd(), `.wasmoon-cwd-test-${Date.now()}.tmp`) - writeFileSync(markerFile, 'cwd-marker') - try { - const result = await state.doString(` - local f = io.open("${markerFile.replace(/\\/g, '/')}", "r") - if not f then return nil end - local content = f:read("*a") - f:close() - return content - `) - expect(result).to.be.equal('cwd-marker') - } finally { - rmSync(markerFile, { force: true }) - } - }) - - it('NODEFS with fsMountPaths should only mount specified paths', async () => { - const tempDir = join(tmpdir(), `wasmoon-mount-test-${Date.now()}`) - mkdirSync(tempDir, { recursive: true }) - const testFile = join(tempDir, 'specific.txt') - writeFileSync(testFile, 'mounted content') - - try { - const lua = await LuaRuntime.load({ fs: 'node', fsMountPaths: [tmpdir()] }) - const state = lua.createState() - const result = await state.doString(` - local f = io.open("${testFile.replace(/\\/g, '/')}", "r") - if not f then return nil end - local content = f:read("*a") - f:close() - return content - `) - expect(result).to.be.equal('mounted content') - } finally { - rmSync(tempDir, { recursive: true, force: true }) - } - }) - - it('NODEFS opening nonexistent file should return nil', async () => { - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await state.doString(` - local f, err = io.open("/tmp/wasmoon_nonexistent_file_${Date.now()}.txt", "r") - return f == nil and type(err) == "string" - `) - expect(result).to.be.true - }) - - it('mountFile and NODEFS should coexist', async () => { - const tempDir = join(tmpdir(), `wasmoon-coexist-${Date.now()}`) - mkdirSync(tempDir, { recursive: true }) - const hostFile = join(tempDir, 'host.txt') - writeFileSync(hostFile, 'from host') - - try { - const lua = await LuaRuntime.load({ fs: 'node' }) - lua.mountFile('virtual.lua', 'return "from virtual"') - const state = lua.createState() - - const hostContent = await state.doString(` - local f = io.open("${hostFile.replace(/\\/g, '/')}", "r") - local content = f:read("*a") - f:close() - return content - `) - const virtualContent = await state.doString('return dofile("virtual.lua")') - - expect(hostContent).to.be.equal('from host') - expect(virtualContent).to.be.equal('from virtual') - } finally { - rmSync(tempDir, { recursive: true, force: true }) - rmSync('virtual.lua', { force: true }) - } - }) - - it('standard libraries are available should succeed', async () => { - const state = (await LuaRuntime.load()).createState() - const result = await state.doString(` - local checks = {} - checks[1] = type(string.format) == "function" - checks[2] = type(table.insert) == "function" - checks[3] = type(math.floor) == "function" - checks[4] = type(os.time) == "function" - checks[5] = type(coroutine.create) == "function" - for _, v in ipairs(checks) do - if not v then return false end - end - return true - `) - expect(result).to.be.true - }) - - it('error handling with pcall should succeed', async () => { - const state = (await LuaRuntime.load()).createState() - const result = await state.doString(` - local ok, err = pcall(function() - error("node test error") - end) - return not ok and string.find(err, "node test error") ~= nil - `) - expect(result).to.be.true - }) - - it('custom stdout should keep multi byte characters intact', async () => { - const output = await collectOutput('stdout', 'print("héllo 日本語 🎉")') - expect(output).to.be.deep.equal(['héllo 日本語 🎉']) - }) - - it('custom stderr should keep multi byte characters intact', async () => { - const errors = await collectOutput('stderr', 'io.stderr:write("erro ✗ 日本\\n")') - expect(errors).to.be.deep.equal(['erro ✗ 日本']) - }) - - it('custom stdout should keep a character that is flushed in the middle intact', async () => { - // The two halves of 日 land in different writes, with the engine handing control back to - // JS in between. - const output = [] - const lua = await LuaRuntime.load({ stdout: (content) => output.push(content) }) - const state = lua.createState() - state.set( - 'pause', - () => - new Promise((resolve) => { - setTimeout(resolve, 0) - }), - ) - await state.doString('io.write("\\xE6") io.flush() pause():await() io.write("\\x97\\xA5 ok\\n")') - expect(output).to.be.deep.equal(['日 ok']) - }) - - it('custom stdout should split on line breaks only', async () => { - const output = await collectOutput('stdout', 'print("first") print("") io.write("a\\rb\\n")') - expect(output).to.be.deep.equal(['first', '', 'a\rb']) - }) - - it('custom stdout should receive flushed output that does not end in a line break', async () => { - const output = await collectOutput('stdout', 'io.write("no newline") io.flush()') - expect(output).to.be.deep.equal(['no newline']) - }) - - it('custom stdout should not split a line that is written in parts', async () => { - const output = await collectOutput('stdout', 'io.write("one ") io.flush() io.write("line\\n")') - expect(output).to.be.deep.equal(['one line']) - }) - - it('custom stdout should handle lines longer than its buffer', async () => { - const output = await collectOutput('stdout', 'print(string.rep("ção ", 1000))') - expect(output).to.be.deep.equal(['ção '.repeat(1000)]) - }) - - it('custom stdin should be read line by line', async () => { - const lua = await LuaRuntime.load({ stdin: stdinFrom(['um\n', 'dois\n']) }) - const result = await lua.createState().doString('return { io.read("l"), io.read("l"), io.read("l") == nil }') - expect(result).to.be.deep.equal(['um', 'dois', true]) - }) - - it('custom stdin should keep multi byte characters intact', async () => { - const lua = await LuaRuntime.load({ stdin: stdinFrom(['héllo 日本語 🎉\n']) }) - const result = await lua.createState().doString('return io.read("l")') - expect(result).to.be.equal('héllo 日本語 🎉') - }) - - it('custom stdin should be read until it signals the end of the input', async () => { - const lua = await LuaRuntime.load({ stdin: stdinFrom(['abc\n', 'déf\n']) }) - const result = await lua.createState().doString('return io.read("a")') - expect(result).to.be.equal('abc\ndéf\n') - }) - - it('cli should read piped input from within a script', async function () { - this.timeout(30_000) - const { code, stdout, stderr } = await runCli( - ['-e', 'print(io.read("l")) print(io.read("l")) print(io.read("l") == nil)'], - 'olá mundo\nsegunda linha\n', - ) - - expect(stderr).to.be.empty - expect(code).to.be.equal(0) - expect(stdout).to.be.equal('olá mundo\nsegunda linha\ntrue\n') - }) - - it('cli should not add a line break to input that does not end with one', async function () { - this.timeout(30_000) - const { code, stdout, stderr } = await runCli(['-e', 'print(#io.read("a"))'], 'abc') - - expect(stderr).to.be.empty - expect(code).to.be.equal(0) - expect(stdout).to.be.equal('3\n') - }) - - it('cli should not run piped input as a script when -e is given', async function () { - this.timeout(30_000) - const { code, stdout, stderr } = await runCli(['-e', 'print("from -e")'], 'this is not lua code\n') - - expect(stderr).to.be.empty - expect(code).to.be.equal(0) - expect(stdout).to.be.equal('from -e\n') - }) - - it('cli should expose arg as a lua table', async function () { - this.timeout(30_000) - const directory = mkdtempSync(join(tmpdir(), 'wasmoon-cli-')) - const script = join(directory, 'args.lua') - writeFileSync(script, 'print(type(arg), #arg, arg[1], arg[2], table.concat(arg, "+"))') - - const { code, stdout, stderr } = await runCli([script, 'first', 'second']) - - expect(stderr).to.be.empty - expect(code).to.be.equal(0) - expect(stdout.trim()).to.be.equal('table\t2\tfirst\tsecond\tfirst+second') - }) -}) diff --git a/test/nodefs.test.js b/test/nodefs.test.js index c83320e..6c85826 100644 --- a/test/nodefs.test.js +++ b/test/nodefs.test.js @@ -1,13 +1,21 @@ -import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs' +import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { expect } from 'chai' import { LuaRuntime } from '../dist/index.js' - -const createTempDir = () => { - const dir = join(tmpdir(), `wasmoon-nodefs-${Date.now()}-${Math.random().toString(36).slice(2)}`) - mkdirSync(dir, { recursive: true }) - return dir +import { luaPath } from './utils.js' + +const createTempDir = () => mkdtempSync(join(tmpdir(), 'wasmoon-nodefs-')) + +// The sentinel distinguishes "empty" from "not reachable at all", which a bare read cannot. +const readFileFromLua = (state, path) => { + return state.doString(` + local f = io.open("${luaPath(path)}", "r") + if not f then return "BLOCKED" end + local content = f:read("*a") + f:close() + return content + `) } describe('Node FS', () => { @@ -26,12 +34,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() - const result = await state.doString(` - local f = io.open("${join(tempDir, 'hello.txt').replace(/\\/g, '/')}", "r") - local content = f:read("*a") - f:close() - return content - `) + const result = await readFileFromLua(state, join(tempDir, 'hello.txt')) expect(result).to.be.equal('hello world') }) @@ -42,7 +45,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() await state.doString(` - local f = io.open("${filePath.replace(/\\/g, '/')}", "w") + local f = io.open("${luaPath(filePath)}", "w") f:write("written from lua") f:close() `) @@ -57,7 +60,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() await state.doString(` - local f = io.open("${filePath.replace(/\\/g, '/')}", "a") + local f = io.open("${luaPath(filePath)}", "a") f:write("second line\\n") f:close() `) @@ -72,7 +75,7 @@ describe('Node FS', () => { const state = lua.createState() const result = await state.doString(` local lines = {} - for line in io.lines("${join(tempDir, 'lines.txt').replace(/\\/g, '/')}") do + for line in io.lines("${luaPath(join(tempDir, 'lines.txt'))}") do lines[#lines + 1] = line end return table.concat(lines, ",") @@ -88,7 +91,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` - local f = io.open("${join(tempDir, 'binary.dat').replace(/\\/g, '/')}", "rb") + local f = io.open("${luaPath(join(tempDir, 'binary.dat'))}", "rb") local data = f:read("*a") f:close() return #data @@ -103,7 +106,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() await state.doString(` - local f = io.open("${filePath.replace(/\\/g, '/')}", "wb") + local f = io.open("${luaPath(filePath)}", "wb") f:write(string.char(72, 101, 108, 108, 111)) f:close() `) @@ -117,11 +120,11 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` - local f = io.open("${join(tempDir, 'exists.txt').replace(/\\/g, '/')}", "r") + local f = io.open("${luaPath(join(tempDir, 'exists.txt'))}", "r") local exists = f ~= nil if f then f:close() end - local f2 = io.open("${join(tempDir, 'nope.txt').replace(/\\/g, '/')}", "r") + local f2 = io.open("${luaPath(join(tempDir, 'nope.txt'))}", "r") local not_exists = f2 == nil if f2 then f2:close() end @@ -137,7 +140,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` - local f = io.open("${join(tempDir, 'sized.txt').replace(/\\/g, '/')}", "r") + local f = io.open("${luaPath(join(tempDir, 'sized.txt'))}", "r") local size = f:seek("end") f:close() return size @@ -152,7 +155,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` - local f = io.open("${join(tempDir, 'seek.txt').replace(/\\/g, '/')}", "r") + local f = io.open("${luaPath(join(tempDir, 'seek.txt'))}", "r") f:seek("set", 3) local chunk = f:read(4) f:close() @@ -167,7 +170,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() - const result = await state.doFile(join(tempDir, 'script.lua').replace(/\\/g, '/')) + const result = await state.doFile(luaPath(join(tempDir, 'script.lua'))) expect(result).to.be.equal(42) }) @@ -180,7 +183,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState({ inject: true }) const result = await state.doString(` - package.path = "${luaDir.replace(/\\/g, '/')}/?.lua;" .. package.path + package.path = "${luaPath(luaDir)}/?.lua;" .. package.path local m = require("mymod") return m.value `) @@ -195,12 +198,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() - const result = await state.doString(` - local f = io.open("${join(nested, 'deep.txt').replace(/\\/g, '/')}", "r") - local content = f:read("*a") - f:close() - return content - `) + const result = await readFileFromLua(state, join(nested, 'deep.txt')) expect(result).to.be.equal('deep content') }) @@ -211,7 +209,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() await state.doString(` - os.execute("mkdir -p '${newDir.replace(/\\/g, '/')}'") + os.execute("mkdir -p '${luaPath(newDir)}'") `) expect(existsSync(newDir)).to.be.true @@ -224,7 +222,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` - return os.remove("${filePath.replace(/\\/g, '/')}") + return os.remove("${luaPath(filePath)}") `) expect(result).to.be.true @@ -239,7 +237,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` - return os.rename("${oldPath.replace(/\\/g, '/')}", "${newPath.replace(/\\/g, '/')}") + return os.rename("${luaPath(oldPath)}", "${luaPath(newPath)}") `) expect(result).to.be.true @@ -247,20 +245,59 @@ describe('Node FS', () => { expect(readFileSync(newPath, 'utf8')).to.be.equal('rename me') }) + it('NODEFS should start in the host process cwd', async () => { + const lua = await LuaRuntime.load({ fs: 'node' }) + + expect(lua.filesystem.cwd()).to.be.equal(process.cwd().replace(/\\/g, '/')) + }) + it('doFile from cwd-relative path should succeed', async () => { - // NODEFS sets cwd to process.cwd(), so relative doFile should work const lua = await LuaRuntime.load({ fs: 'node' }) + // chdir first, or the mounted file lands in the repo the suite runs from. + lua.filesystem.chdir(tempDir) lua.mountFile('cwd_test.lua', 'return "from cwd"') + const state = lua.createState() + + expect(existsSync(join(tempDir, 'cwd_test.lua'))).to.be.true + expect(await state.doFile('cwd_test.lua')).to.be.equal('from cwd') + }) + + it('fsMountPaths should mount the listed path', async () => { + writeFileSync(join(tempDir, 'inside.txt'), 'mounted content') + + const lua = await LuaRuntime.load({ fs: 'node', fsMountPaths: [tempDir] }) + const state = lua.createState() + + expect(await readFileFromLua(state, join(tempDir, 'inside.txt'))).to.be.equal('mounted content') + }) + + it('fsMountPaths should leave everything else unreachable', async () => { + // Without this the default mounts the whole drive, so the case above passes either way. + const unmounted = createTempDir() + writeFileSync(join(unmounted, 'outside.txt'), 'should not be readable') + try { + const lua = await LuaRuntime.load({ fs: 'node', fsMountPaths: [tempDir] }) const state = lua.createState() - const result = await state.doFile('cwd_test.lua') - expect(result).to.be.equal('from cwd') + expect(await readFileFromLua(state, join(unmounted, 'outside.txt'))).to.be.equal('BLOCKED') } finally { - rmSync('cwd_test.lua', { force: true }) + rmSync(unmounted, { recursive: true, force: true }) } }) + it('mountFile and NODEFS should coexist', async () => { + writeFileSync(join(tempDir, 'host.txt'), 'from host') + + const lua = await LuaRuntime.load({ fs: 'node' }) + lua.filesystem.chdir(tempDir) + lua.mountFile('virtual.lua', 'return "from virtual"') + const state = lua.createState() + + expect(await readFileFromLua(state, join(tempDir, 'host.txt'))).to.be.equal('from host') + expect(await state.doString('return dofile("virtual.lua")')).to.be.equal('from virtual') + }) + it('write large file should succeed', async () => { const filePath = join(tempDir, 'large.txt') const lineCount = 10000 @@ -268,7 +305,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() await state.doString(` - local f = io.open("${filePath.replace(/\\/g, '/')}", "w") + local f = io.open("${luaPath(filePath)}", "w") for i = 1, ${lineCount} do f:write("line " .. i .. "\\n") end @@ -289,7 +326,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() await state.doString(` - local f = io.open("${filePath.replace(/\\/g, '/')}", "w") + local f = io.open("${luaPath(filePath)}", "w") f:write("new content") f:close() `) @@ -303,12 +340,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() - const result = await state.doString(` - local f = io.open("${join(tempDir, 'utf8.txt').replace(/\\/g, '/')}", "r") - local data = f:read("*a") - f:close() - return data - `) + const result = await readFileFromLua(state, join(tempDir, 'utf8.txt')) expect(result).to.be.equal(content) }) @@ -319,7 +351,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() await state.doString(` - local f = io.open("${filePath.replace(/\\/g, '/')}", "w") + local f = io.open("${luaPath(filePath)}", "w") f:write("café ñ 中文") f:close() `) @@ -333,7 +365,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` - local f = io.open("${join(tempDir, 'empty.txt').replace(/\\/g, '/')}", "r") + local f = io.open("${luaPath(join(tempDir, 'empty.txt'))}", "r") local data = f:read("*a") f:close() return #data @@ -342,12 +374,12 @@ describe('Node FS', () => { expect(result).to.be.equal(0) }) - it('open non-existent file for reading should return nil', async () => { + it('open non-existent file for reading should return nil and a message', async () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` - local f, err = io.open("${join(tempDir, 'nonexistent.txt').replace(/\\/g, '/')}", "r") - return f == nil + local f, err = io.open("${luaPath(join(tempDir, 'nonexistent.txt'))}", "r") + return f == nil and type(err) == "string" and #err > 0 `) expect(result).to.be.true @@ -376,13 +408,13 @@ describe('Node FS', () => { const state2 = lua.createState() await state1.doString(` - local f = io.open("${filePath.replace(/\\/g, '/')}", "w") + local f = io.open("${luaPath(filePath)}", "w") f:write("from state1") f:close() `) const result = await state2.doString(` - local f = io.open("${filePath.replace(/\\/g, '/')}", "r") + local f = io.open("${luaPath(filePath)}", "r") local data = f:read("*a") f:close() return data @@ -397,7 +429,7 @@ describe('Node FS', () => { const lua = await LuaRuntime.load({ fs: 'node' }) const state = lua.createState() const result = await state.doString(` - local f = io.open("${join(tempDir, 'numbers.txt').replace(/\\/g, '/')}", "r") + local f = io.open("${luaPath(join(tempDir, 'numbers.txt'))}", "r") local n1 = f:read("*n") local n2 = f:read("*n") local n3 = f:read("*n") diff --git a/test/promises.test.js b/test/promises.test.js index 0058306..17947fc 100644 --- a/test/promises.test.js +++ b/test/promises.test.js @@ -4,7 +4,7 @@ import { mock } from 'node:test' describe('Promises', () => { it('use promise next should succeed', async () => { - const state = await getState() + using state = await getState() const check = mock.fn() state.set('check', check) const promise = new Promise((resolve) => setTimeout(() => resolve(60), 5)) @@ -21,7 +21,7 @@ describe('Promises', () => { }) it('chain promises with next should succeed', async () => { - const state = await getState() + using state = await getState() const check = mock.fn() state.set('check', check) const promise = new Promise((resolve) => resolve(60)) @@ -42,7 +42,7 @@ describe('Promises', () => { }) it('call an async function should succeed', async () => { - const state = await getState() + using state = await getState() state.set('asyncFunction', async () => Promise.resolve(60)) const check = mock.fn() state.set('check', check) @@ -57,7 +57,7 @@ describe('Promises', () => { }) it('return an async function should succeed', async () => { - const state = await getState() + using state = await getState() state.set('asyncFunction', async () => Promise.resolve(60)) const asyncFunction = await state.doString(` @@ -69,7 +69,7 @@ describe('Promises', () => { }) it('return a chained promise should succeed', async () => { - const state = await getState() + using state = await getState() state.set('asyncFunction', async () => Promise.resolve(60)) const asyncFunction = await state.doString(` @@ -81,7 +81,7 @@ describe('Promises', () => { }) it('await an promise inside coroutine should succeed', async () => { - const state = await getState() + using state = await getState() const check = mock.fn() state.set('check', check) const promise = new Promise((resolve) => setTimeout(() => resolve(60), 5)) @@ -108,7 +108,7 @@ describe('Promises', () => { }) it('awaited coroutines should ignore resume until it resolves the promise', async () => { - const state = await getState() + using state = await getState() const check = mock.fn() state.set('check', check) const promise = new Promise((resolve) => setTimeout(() => resolve(60), 5)) @@ -133,7 +133,7 @@ describe('Promises', () => { }) it('await a thread run with async calls should succeed', async () => { - const state = await getState() + using state = await getState() state.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) const asyncThread = state.newThread() @@ -147,7 +147,7 @@ describe('Promises', () => { }) it('run thread with async calls and yields should succeed', async () => { - const state = await getState() + using state = await getState() state.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) const asyncThread = state.newThread() @@ -165,7 +165,7 @@ describe('Promises', () => { }) it('reject a promise should succeed', async () => { - const state = await getState() + using state = await getState() state.set('throw', () => new Promise((_, reject) => reject(new Error('expected test error')))) const asyncThread = state.newThread() @@ -178,7 +178,7 @@ describe('Promises', () => { }) it('pcall a promise await should succeed', async () => { - const state = await getState() + using state = await getState() state.set('throw', () => new Promise((_, reject) => reject(new Error('expected test error')))) const asyncThread = state.newThread() @@ -192,7 +192,7 @@ describe('Promises', () => { }) it('catch a promise rejection should succeed', async () => { - const state = await getState() + using state = await getState() const fulfilled = mock.fn() const rejected = mock.fn() state.set('handlers', { fulfilled, rejected }) @@ -209,7 +209,7 @@ describe('Promises', () => { }) it('run with async callback', async () => { - const state = await getState() + using state = await getState() const thread = state.newThread() state.set('asyncCallback', async (input) => { @@ -232,7 +232,7 @@ describe('Promises', () => { }) it('promise creation from js', async () => { - const state = await getState() + using state = await getState() const res = await state.doString(` local promise = Promise.create(function (resolve) resolve(10) @@ -248,7 +248,7 @@ describe('Promises', () => { }) it('reject promise creation from js', async () => { - const state = await getState() + using state = await getState() const res = await state.doString(` local rejection = Promise.create(function (resolve, reject) reject("expected rejection") @@ -261,7 +261,7 @@ describe('Promises', () => { }) it('resolve multiple promises with promise.all', async () => { - const state = await getState() + using state = await getState() state.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) const resPromise = state.doString(` local promises = {} @@ -278,7 +278,7 @@ describe('Promises', () => { }) it('error in promise next catchable', async () => { - const state = await getState() + using state = await getState() state.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) const resPromise = state.doString(` return sleep(1):next(function () @@ -289,7 +289,7 @@ describe('Promises', () => { }) it('should not be possible to await in synchronous run', async () => { - const state = await getState() + using state = await getState() state.set('sleep', (input) => new Promise((resolve) => setTimeout(resolve, input))) expect(() => { diff --git a/test/state.test.js b/test/state.test.js index 82bcf02..92decdd 100644 --- a/test/state.test.js +++ b/test/state.test.js @@ -35,7 +35,7 @@ describe('State', () => { }) it('receive lua table on JS function should succeed', async () => { - const state = await getState() + using state = await getState() state.set('stringify', (table) => { return JSON.stringify(table) }) @@ -46,7 +46,7 @@ describe('State', () => { }) it('get a global table inside a JS function called by lua should succeed', async () => { - const state = await getState() + using state = await getState() state.set('t', { test: 1 }) state.set('test', () => { return state.get('t') @@ -58,7 +58,7 @@ describe('State', () => { }) it('receive JS object on lua should succeed', async () => { - const state = await getState() + using state = await getState() state.set('test', () => { return { @@ -75,7 +75,7 @@ describe('State', () => { }) it('receive JS object with circular references on lua should succeed', async () => { - const state = await getState() + using state = await getState() const obj = { hello: 'world', } @@ -88,7 +88,7 @@ describe('State', () => { }) it('receive Lua object with circular references on JS should succeed', async () => { - const state = await getState() + using state = await getState() const value = await state.doString(` local obj1 = { hello = 'world', @@ -124,7 +124,7 @@ describe('State', () => { }) it('receive lua array with circular references on JS should succeed', async () => { - const state = await getState() + using state = await getState() const value = await state.doString(` obj = { "hello", @@ -140,7 +140,7 @@ describe('State', () => { }) it('receive JS object with multiple circular references on lua should succeed', async () => { - const state = await getState() + using state = await getState() const obj1 = { hello: 'world', } @@ -158,7 +158,7 @@ describe('State', () => { }) it('receive JS object with null prototype on lua should succeed', async () => { - const state = await getState() + using state = await getState() const obj = Object.create(null) obj.hello = 'world' state.set('obj', obj) @@ -169,7 +169,7 @@ describe('State', () => { }) it('inherited properties should not be copied into the lua table', async () => { - const state = await getState({ objects: 'copy' }) + using state = await getState({ objects: 'copy' }) const obj = Object.create({ inherited: 'yes' }) obj.own = 'mine' state.set('t', obj) @@ -185,7 +185,7 @@ describe('State', () => { }) it('non enumerable properties should not be copied into the lua table', async () => { - const state = await getState({ objects: 'copy' }) + using state = await getState({ objects: 'copy' }) const obj = { own: 'mine' } Object.defineProperty(obj, 'hidden', { value: 1, enumerable: false }) state.set('t', obj) @@ -195,7 +195,7 @@ describe('State', () => { }) it('nested arrays and objects should be copied into the lua table', async () => { - const state = await getState({ objects: 'copy' }) + using state = await getState({ objects: 'copy' }) state.set('t', { list: [10, 20, 30], nested: { deep: [{ value: 5 }] } }) expect(await state.doString('return #t.list')).to.be.equal(3) @@ -203,14 +203,14 @@ describe('State', () => { expect(await state.doString('return t.nested.deep[1].value')).to.be.equal(5) }) - it('a lua error should throw on JS', async () => { - const state = await getState() + it('a lua syntax error should throw on JS', async () => { + using state = await getState() - await expect(state.doString(`x -`)).to.eventually.be.rejected + await expect(state.doString(`x -`)).to.eventually.be.rejectedWith(/syntax error near/) }) it('call a lua function from JS should succeed', async () => { - const state = await getState() + using state = await getState() await state.doString(`function sum(x, y) return x + y end`) const sum = state.get('sum') @@ -219,7 +219,7 @@ describe('State', () => { }) it('scheduled lua calls should succeed', async () => { - const state = await getState() + using state = await getState() state.set('setInterval', setIntervalSafe) await state.doString(` @@ -247,7 +247,7 @@ describe('State', () => { }) it('calling a lua function after close should throw', async () => { - const state = await getState() + using state = await getState() const callback = await state.doString(` test = 0 @@ -261,7 +261,7 @@ describe('State', () => { }) it('call lua function from JS passing an array argument should succeed', async () => { - const state = await getState() + using state = await getState() const sum = await state.doString(` return function(arr) @@ -277,7 +277,7 @@ describe('State', () => { }) it('call a global function with multiple returns should succeed', async () => { - const state = await getState() + using state = await getState() await state.doString(` function f(x,y) @@ -292,7 +292,7 @@ describe('State', () => { }) it('get a lua thread should succeed', async () => { - const state = await getState() + using state = await getState() const thread = await state.doString(` return coroutine.create(function() @@ -305,7 +305,7 @@ describe('State', () => { }) it('a JS error should pause lua execution', async () => { - const state = await getState() + using state = await getState() const check = mock.fn() state.set('check', check) state.set('throw', () => { @@ -322,7 +322,7 @@ describe('State', () => { }) it('catch a JS error with pcall should succeed', async () => { - const state = await getState() + using state = await getState() const check = mock.fn() state.set('check', check) state.set('throw', () => { @@ -340,7 +340,7 @@ describe('State', () => { }) it('call a JS function in a different thread should succeed', async () => { - const state = await getState() + using state = await getState() const sum = mock.fn((x, y) => x + y) state.set('sum', sum) @@ -355,7 +355,7 @@ describe('State', () => { }) it('get callable table as function should succeed', async () => { - const state = await getState() + using state = await getState() await state.doString(` _G['sum'] = setmetatable({}, { __call = function(self, x, y) @@ -371,7 +371,7 @@ describe('State', () => { }) it('lua_resume with yield succeeds', async () => { - const state = await getState() + using state = await getState() const thread = state.newThread() thread.loadString(` local yieldRes = coroutine.yield(10) @@ -397,12 +397,12 @@ describe('State', () => { }) it('get memory with allocation tracing should succeeds', async () => { - const state = await getState({ memory: { trace: true } }) + using state = await getState({ memory: { trace: true } }) expect(state.memory.used).to.be.greaterThan(0) }) it('get memory should return correct', async () => { - const state = await getState({ memory: { trace: true } }) + using state = await getState({ memory: { trace: true } }) const totalMemory = await state.doString(` collectgarbage() @@ -415,13 +415,13 @@ describe('State', () => { }) it('memory is undefined without tracing', async () => { - const state = await getState() + using state = await getState() expect(state.memory).to.be.undefined }) it('limit memory use causes program loading failure succeeds', async () => { - const state = await getState({ memory: { trace: true } }) + using state = await getState({ memory: { trace: true } }) state.memory.max = state.memory.used expect(() => { state.loadString(` @@ -441,7 +441,7 @@ describe('State', () => { }) it('limit memory use causes program runtime failure succeeds', async () => { - const state = await getState({ memory: { trace: true } }) + using state = await getState({ memory: { trace: true } }) state.loadString(` local tab = {} for i = 1, 50, 1 do @@ -454,7 +454,7 @@ describe('State', () => { }) it('table supported circular dependencies', async () => { - const state = await getState() + using state = await getState() const a = { name: 'a' } const b = { name: 'b' } @@ -468,7 +468,7 @@ describe('State', () => { }) it('wrap a js object (with metatable)', async () => { - const state = await getState() + using state = await getState() state.set('TestClass', { create: (name) => { return decorate( @@ -498,7 +498,7 @@ describe('State', () => { }) it('wrap a js object using proxy', async () => { - const state = await getState() + using state = await getState() state.set('TestClass', { create: (name) => new TestClass(name), }) @@ -510,7 +510,7 @@ describe('State', () => { }) it('wrap a js object using proxy and apply metatable in lua', async () => { - const state = await getState() + using state = await getState() state.set('TestClass', { create: (name) => new TestClass(name), }) @@ -539,7 +539,7 @@ describe('State', () => { }) it('classes should be a userdata when proxied', async () => { - const state = await getState() + using state = await getState() state.set('obj', { TestClass }) const testClass = await state.doString(` @@ -550,7 +550,7 @@ describe('State', () => { }) it('timeout blocking lua program', async () => { - const state = await getState() + using state = await getState() state.loadString(` local i = 0 while true do i = i + 1 end @@ -561,7 +561,7 @@ describe('State', () => { it('the most recently set timeout should be the one that applies', async function () { this.timeout(30_000) - const state = await getState() + using state = await getState() const thread = state.newThread() thread.setDeadline(Date.now() + 20) @@ -573,7 +573,7 @@ describe('State', () => { it('clearing a timeout should disable the hook', async function () { this.timeout(30_000) - const state = await getState() + using state = await getState() const thread = state.newThread() thread.setDeadline(Date.now() + 10) @@ -585,7 +585,7 @@ describe('State', () => { }) it('overwrite lib function', async () => { - const state = await getState() + using state = await getState() let output = '' state.getTable('_G', (index) => { @@ -604,7 +604,7 @@ describe('State', () => { }) it('inject a userdata with a metatable should succeed', async () => { - const state = await getState() + using state = await getState() const obj = decorate( {}, { @@ -619,7 +619,7 @@ describe('State', () => { }) it('a userdata should be collected', async () => { - const state = await getState() + using state = await getState() const obj = {} state.set('obj', obj) const refIndex = state.lua.getLastRefIndex() @@ -640,7 +640,7 @@ describe('State', () => { }) it('environment variables should be set', async () => { - const lua = await getLua({ TEST: 'true' }) + const lua = await getLua({ env: { TEST: 'true' } }) const state = lua.createState() const testEnvVar = await state.doString(`return os.getenv('TEST')`) @@ -649,7 +649,7 @@ describe('State', () => { }) it('static methods should be callable on classes', async () => { - const state = await getState() + using state = await getState() state.set('TestClass', TestClass) const testHello = await state.doString(`return TestClass.hello()`) @@ -658,7 +658,7 @@ describe('State', () => { }) it('should be possible to access function properties', async () => { - const state = await getState() + using state = await getState() const testFunction = () => undefined testFunction.hello = 'world' state.set('TestFunction', decorate(testFunction, { as: 'proxy' })) @@ -669,7 +669,7 @@ describe('State', () => { }) it('throw error includes stack trace', async () => { - const state = await getState() + using state = await getState() try { await state.doString(` local function a() @@ -695,7 +695,7 @@ describe('State', () => { }) it('should get only the last result on run', async () => { - const state = await getState() + using state = await getState() const a = await state.doString(`return 1`) const b = await state.doString(`return 3`) @@ -709,7 +709,7 @@ describe('State', () => { }) it('should get only the return values on call function', async () => { - const state = await getState() + using state = await getState() state.set('hello', (name) => `Hello ${name}!`) const a = await state.doString(`return 1`) @@ -723,7 +723,7 @@ describe('State', () => { }) it('create a large string variable should succeed', async () => { - const state = await getState() + using state = await getState() const str = 'a'.repeat(1000000) state.set('str', str) @@ -734,7 +734,7 @@ describe('State', () => { }) it('execute a large string should succeed', async () => { - const state = await getState() + using state = await getState() const str = 'a'.repeat(1000000) const res = await state.doString(`return [[${str}]]`) @@ -744,7 +744,7 @@ describe('State', () => { it('a large multibyte string should keep its byte length', async function () { this.timeout(30_000) - const state = await getState() + using state = await getState() const str = 'á'.repeat(500000) state.set('str', str) @@ -754,14 +754,14 @@ describe('State', () => { }) it('a string containing NUL should be pushed with its full length', async () => { - const state = await getState() + using state = await getState() state.set('str', 'a\0b') expect(await state.doString('return #str')).to.be.equal(3) }) it('a string containing NUL should be retrieved with its full length', async () => { - const state = await getState() + using state = await getState() const res = await state.doString('return "x\\0y"') @@ -769,7 +769,7 @@ describe('State', () => { }) it('a string containing NUL should round trip unchanged', async () => { - const state = await getState() + using state = await getState() const str = 'before\0middle\0after' state.set('str', str) @@ -777,7 +777,7 @@ describe('State', () => { }) it('an empty string should round trip unchanged', async () => { - const state = await getState() + using state = await getState() state.set('str', '') expect(await state.doString('return #str')).to.be.equal(0) @@ -785,7 +785,7 @@ describe('State', () => { }) it('getStringBytes should expose bytes that are not valid UTF-8', async () => { - const state = await getState() + using state = await getState() await state.doString('value = string.char(0, 255, 128, 65)') state.lua.lua_getglobal(state.address, 'value') @@ -796,7 +796,7 @@ describe('State', () => { }) it('getStringBytes should return undefined for a non string value', async () => { - const state = await getState() + using state = await getState() state.pushValue({}) expect(state.getStringBytes(-1)).to.be.undefined @@ -805,7 +805,7 @@ describe('State', () => { }) it('bytecode should round trip through the byte accessors', async () => { - const state = await getState() + using state = await getState() await state.doString('bytecode = string.dump(load("return 42"))') state.lua.lua_getglobal(state.address, 'bytecode') @@ -819,7 +819,7 @@ describe('State', () => { }) it('negative integers should be pushed and retrieved as string', async () => { - const state = await getState() + using state = await getState() state.set('value', -1) const res = await state.doString(`return tostring(value)`) @@ -828,7 +828,7 @@ describe('State', () => { }) it('negative integers should be pushed and retrieved as number', async () => { - const state = await getState() + using state = await getState() state.set('value', -1) const res = await state.doString(`return value`) @@ -837,7 +837,7 @@ describe('State', () => { }) it('number greater than 32 bit int should be pushed and retrieved as string', async () => { - const state = await getState() + using state = await getState() const value = 1689031554550 state.set('value', value) @@ -847,7 +847,7 @@ describe('State', () => { }) it('number greater than 32 bit int should be pushed and retrieved as number', async () => { - const state = await getState() + using state = await getState() const value = 1689031554550 state.set('value', value) @@ -857,7 +857,7 @@ describe('State', () => { }) it('number greater than 32 bit int should be usable as a format argument', async () => { - const state = await getState() + using state = await getState() const value = 1689031554550 state.set('value', value) @@ -867,7 +867,7 @@ describe('State', () => { }) it('64-bit integers pushed through the raw Lua API should keep integer semantics', async () => { - const state = await getState() + using state = await getState() const value = 9223372036854775807n state.lua.lua_pushinteger(state.address, value) @@ -886,14 +886,14 @@ describe('State', () => { }) it('integers outside the JS safe range should be retrieved as bigint', async () => { - const state = await getState() + using state = await getState() expect(await state.doString('return math.maxinteger')).to.be.equal(9223372036854775807n) expect(await state.doString('return math.mininteger')).to.be.equal(-9223372036854775808n) }) it('integers inside the JS safe range should be retrieved as number', async () => { - const state = await getState() + using state = await getState() const res = await state.doString('return 1689031554550') @@ -902,21 +902,21 @@ describe('State', () => { }) it('an integral number should be pushed as a lua integer', async () => { - const state = await getState() + using state = await getState() state.set('value', 2) expect(await state.doString('return math.type(value)')).to.be.equal('integer') }) it('floats should be retrieved as number', async () => { - const state = await getState() + using state = await getState() expect(await state.doString('return 1.5')).to.be.equal(1.5) expect(await state.doString('return math.type(1.5)')).to.be.equal('float') }) it('a bigint should be pushed as a 64 bit lua integer', async () => { - const state = await getState() + using state = await getState() state.set('value', 9223372036854775807n) expect(await state.doString('return tostring(value)')).to.be.equal('9223372036854775807') @@ -925,13 +925,13 @@ describe('State', () => { }) it('a bigint outside the lua integer range should throw', async () => { - const state = await getState() + using state = await getState() expect(() => state.set('value', 2n ** 70n)).to.throw(RangeError) }) it('an integral number too large for a lua integer should be pushed as a float', async () => { - const state = await getState() + using state = await getState() state.set('value', 1e300) expect(await state.doString('return math.type(value)')).to.be.equal('float') @@ -942,7 +942,7 @@ describe('State', () => { // When yielding within a callback the error 'attempt to yield across a C-call boundary'. // This test just checks that throwing that error still allows the lua global to be // re-used and doesn't cause JS to abort or some nonsense. - const state = await getState() + using state = await getState() const testEmitter = new EventEmitter() state.set('yield', () => new Promise((resolve) => testEmitter.once('resolve', resolve))) const resPromise = state.doString(` @@ -960,7 +960,7 @@ describe('State', () => { }) it('forced yield within JS callback from Lua doesnt cause vm to crash', async () => { - const state = await getState({ limits: { functionTimeout: 10 } }) + using state = await getState({ limits: { functionTimeout: 10 } }) state.set('promise', Promise.resolve()) const thread = state.newThread() thread.loadString(` @@ -976,7 +976,7 @@ describe('State', () => { }) it('function callback timeout still allows timeout of caller thread', async () => { - const state = await getState() + using state = await getState() state.set('promise', Promise.resolve()) const thread = state.newThread() thread.loadString(` @@ -989,7 +989,7 @@ describe('State', () => { }) it('null injected and valid', async () => { - const state = await getState() + using state = await getState() state.loadString(` local args = { ... } assert(args[1] == null, string.format("expected first argument to be null, got %s", tostring(args[1]))) @@ -1001,7 +1001,7 @@ describe('State', () => { }) it('null injected as nil', async () => { - const state = await getState({ inject: false }) + using state = await getState({ inject: false }) state.loadString(` local args = { ... } assert(type(args[1]) == "nil", string.format("expected first argument to be nil, got %s", type(args[1]))) @@ -1013,7 +1013,7 @@ describe('State', () => { }) it('reassigning the null global should not affect pushing null', async () => { - const state = await getState() + using state = await getState() await state.doString('null = 5') state.set('value', null) @@ -1023,14 +1023,14 @@ describe('State', () => { }) it('a pushed null should equal the injected null global', async () => { - const state = await getState() + using state = await getState() state.set('value', null) expect(await state.doString('return value == null')).to.be.true }) it('Nested callback from JS to Lua', async () => { - const state = await getState() + using state = await getState() state.set('call', (fn) => fn()) const res = await state.doString(` return call(function () @@ -1043,7 +1043,7 @@ describe('State', () => { }) it('lots of doString calls should succeed', async () => { - const state = await getState() + using state = await getState() const length = 10000 for (let i = 0; i < length; i++) { @@ -1056,7 +1056,7 @@ describe('State', () => { it('lots of doStringSync calls should not grow the lua stack', async function () { this.timeout(30_000) - const state = await getState() + using state = await getState() const startTop = state.getTop() for (let i = 0; i < 500; i++) { @@ -1067,7 +1067,7 @@ describe('State', () => { }) it('a failing doStringSync should not grow the lua stack', async () => { - const state = await getState() + using state = await getState() const startTop = state.getTop() expect(() => state.doStringSync('error("boom")')).to.throw('boom') @@ -1076,7 +1076,7 @@ describe('State', () => { }) it('values returned by doStringSync should outlive the stack reset', async () => { - const state = await getState() + using state = await getState() const fn = state.doStringSync('return function() return 3 end') const table = state.doStringSync('return { a = 1, b = { 2, 3 } }') @@ -1087,7 +1087,7 @@ describe('State', () => { it('many concurrent doString calls should succeed', async function () { this.timeout(15000) - const state = await getState() + using state = await getState() const length = 55 const promises = [] @@ -1104,7 +1104,7 @@ describe('State', () => { describe('Load mode', () => { it('loadString refuses bytecode by default', async () => { - const state = await getState() + using state = await getState() // Produced and reloaded inside Lua so the bytes never round trip through a JS string. expect( state.doStringSync(` @@ -1116,13 +1116,13 @@ describe('Load mode', () => { }) it('the host loader refuses a binary chunk by default', async () => { - const state = await getState() + using state = await getState() expect(() => state.loadString('\x1bLua\x55\x00')).to.throw('attempt to load a binary chunk') }) it('mode bt opts back in', async () => { - const state = await getState() + using state = await getState() expect( state.doStringSync(` @@ -1144,7 +1144,7 @@ describe('Decoration', () => { } it('as userdata hides the members', async () => { - const state = await getState() + using state = await getState() state.set('thing', decorate(new Thing('bob'), { as: 'userdata' })) // An opaque userdata has no __index of its own, so Lua refuses to index it at all. @@ -1152,7 +1152,7 @@ describe('Decoration', () => { }) it('as userdata still cannot take a metatable of its own', async () => { - const state = await getState() + using state = await getState() // The userdata extension owns the metatable slot. That is a constraint of the extension, // not of the decoration API, so a custom metatable has to go on a wrapper. @@ -1162,7 +1162,7 @@ describe('Decoration', () => { }) it('a wrapper carries the metatable around an opaque userdata', async () => { - const state = await getState() + using state = await getState() state.set( 'thing', decorate( @@ -1175,14 +1175,14 @@ describe('Decoration', () => { }) it('as value copies an object instead of proxying it', async () => { - const state = await getState() + using state = await getState() state.set('plain', decorate({ a: 1 }, { as: 'value' })) expect(await state.doString('return type(plain)')).to.be.equal('table') }) it('as proxy forces a function through the proxy layer', async () => { - const state = await getState() + using state = await getState() const fn = () => undefined fn.hello = 'world' state.set('fn', decorate(fn, { as: 'proxy' })) @@ -1210,7 +1210,7 @@ describe('Memory', () => { describe('Synchronous runs', () => { it('do not leak stack onto the state between calls', async () => { - const state = await getState() + using state = await getState() const top = state.getTop() for (let i = 0; i < 50; i++) { @@ -1221,7 +1221,7 @@ describe('Synchronous runs', () => { }) it('a hook installed for a sync run does not outlive it', async () => { - const state = await getState() + using state = await getState() state.doStringSync('return 1', { timeout: 1000 }) diff --git a/test/stdio.test.js b/test/stdio.test.js new file mode 100644 index 0000000..f34e000 --- /dev/null +++ b/test/stdio.test.js @@ -0,0 +1,112 @@ +import { expect } from 'chai' +import { LuaRuntime } from '../dist/index.js' + +const collectOutput = async (stream, script) => { + const output = [] + const lua = await LuaRuntime.load({ [stream]: (content) => output.push(content) }) + await lua.createState().doString(script) + return output +} + +// The empty string is what signals EOF, so reads past the end have to return it rather than undefined. +const stdinFrom = (chunks) => { + let index = 0 + return () => chunks[index++] ?? '' +} + +describe('Custom stdout', () => { + it('should receive what the script prints', async () => { + const output = await collectOutput('stdout', 'print("hello from node")') + + expect(output).to.be.deep.equal(['hello from node']) + }) + + it('should keep multi byte characters intact', async () => { + const output = await collectOutput('stdout', 'print("héllo 日本語 🎉")') + + expect(output).to.be.deep.equal(['héllo 日本語 🎉']) + }) + + it('should keep a character that is flushed in the middle intact', async () => { + // The two halves of 日 land in different writes, with the engine handing control back to + // JS in between. + const output = [] + const lua = await LuaRuntime.load({ stdout: (content) => output.push(content) }) + const state = lua.createState() + state.set( + 'pause', + () => + new Promise((resolve) => { + setTimeout(resolve, 0) + }), + ) + + await state.doString('io.write("\\xE6") io.flush() pause():await() io.write("\\x97\\xA5 ok\\n")') + + expect(output).to.be.deep.equal(['日 ok']) + }) + + it('should split on line breaks only', async () => { + const output = await collectOutput('stdout', 'print("first") print("") io.write("a\\rb\\n")') + + expect(output).to.be.deep.equal(['first', '', 'a\rb']) + }) + + it('should receive flushed output that does not end in a line break', async () => { + const output = await collectOutput('stdout', 'io.write("no newline") io.flush()') + + expect(output).to.be.deep.equal(['no newline']) + }) + + it('should not split a line that is written in parts', async () => { + const output = await collectOutput('stdout', 'io.write("one ") io.flush() io.write("line\\n")') + + expect(output).to.be.deep.equal(['one line']) + }) + + it('should handle lines longer than its buffer', async () => { + const output = await collectOutput('stdout', 'print(string.rep("ção ", 1000))') + + expect(output).to.be.deep.equal(['ção '.repeat(1000)]) + }) +}) + +describe('Custom stderr', () => { + it('should receive what the script writes', async () => { + const errors = await collectOutput('stderr', 'io.stderr:write("error output\\n")') + + expect(errors).to.be.deep.equal(['error output']) + }) + + it('should keep multi byte characters intact', async () => { + const errors = await collectOutput('stderr', 'io.stderr:write("erro ✗ 日本\\n")') + + expect(errors).to.be.deep.equal(['erro ✗ 日本']) + }) +}) + +describe('Custom stdin', () => { + it('should be read line by line', async () => { + const lua = await LuaRuntime.load({ stdin: stdinFrom(['um\n', 'dois\n']) }) + + const result = await lua.createState().doString('return { io.read("l"), io.read("l"), io.read("l") == nil }') + + expect(result).to.be.deep.equal(['um', 'dois', true]) + }) + + it('should keep multi byte characters intact', async () => { + const lua = await LuaRuntime.load({ stdin: stdinFrom(['héllo 日本語 🎉\n']) }) + + const result = await lua.createState().doString('return io.read("l")') + + expect(result).to.be.equal('héllo 日本語 🎉') + }) + + it('should be read until it signals the end of the input', async () => { + const lua = await LuaRuntime.load({ stdin: stdinFrom(['abc\n', 'déf\n']) }) + + const result = await lua.createState().doString('return io.read("a")') + + expect(result).to.be.equal('abc\ndéf\n') + }) +}) diff --git a/test/utils.js b/test/utils.js index ee59609..f0343b9 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,7 +1,7 @@ import { LuaRuntime } from '../dist/index.js' -export const getLua = (env) => { - return LuaRuntime.load({ env }) +export const getLua = (options) => { + return LuaRuntime.load(options) } export const getState = async (config = {}) => { @@ -16,3 +16,6 @@ export const getState = async (config = {}) => { export const tick = () => { return new Promise((resolve) => setImmediate(resolve)) } + +/** Windows paths cannot go into a Lua string literal as they are. */ +export const luaPath = (path) => path.replace(/\\/g, '/') From 266037aa9579b2ca33aaf283e7a562e2fa38abbd Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Tue, 28 Jul 2026 23:21:20 -0300 Subject: [PATCH 45/52] small api changes --- bench/comparisons.js | 6 +- bench/steps.js | 6 +- package.json | 2 +- src/index.ts | 1 + src/module.ts | 41 +++++++--- src/runtime.ts | 15 ++-- src/state.ts | 44 +++++----- src/thread.ts | 141 +++++++++++++++++--------------- src/type-extension.ts | 24 +++--- src/type-extensions/error.ts | 20 ++--- src/type-extensions/function.ts | 64 +++++++-------- src/type-extensions/null.ts | 32 ++++---- src/type-extensions/promise.ts | 32 ++++---- src/type-extensions/proxy.ts | 36 ++++---- src/type-extensions/table.ts | 24 +++--- src/type-extensions/userdata.ts | 22 ++--- src/types.ts | 10 +-- test/api.test.js | 16 ++-- test/errors.test.js | 6 +- test/initialization.test.js | 7 +- test/state.test.js | 35 +++++--- utils/reference-types.js | 17 ++++ 22 files changed, 326 insertions(+), 275 deletions(-) create mode 100644 utils/reference-types.js diff --git a/bench/comparisons.js b/bench/comparisons.js index 20d81d3..8ee2d52 100644 --- a/bench/comparisons.js +++ b/bench/comparisons.js @@ -20,9 +20,9 @@ function createWasmoonIteration(lua) { return function runWasmoonIteration() { const state = lua.createState() try { - assertStatus(state.lua.luaL_loadstring(state.address, heapsort), 'Wasmoon load') - assertStatus(state.lua.lua_pcallk(state.address, 0, 1, 0, 0, null), 'Wasmoon compile') - assertStatus(state.lua.lua_pcallk(state.address, 0, 1, 0, 0, null), 'Wasmoon execute') + assertStatus(state.module.luaL_loadstring(state.address, heapsort), 'Wasmoon load') + assertStatus(state.module.lua_pcallk(state.address, 0, 1, 0, 0, null), 'Wasmoon compile') + assertStatus(state.module.lua_pcallk(state.address, 0, 1, 0, 0, null), 'Wasmoon execute') } finally { state.close() } diff --git a/bench/steps.js b/bench/steps.js index 8a6d8a8..19ea962 100644 --- a/bench/steps.js +++ b/bench/steps.js @@ -47,9 +47,9 @@ function createRawHeapsortBenchmark(lua) { return function runRawHeapsort() { const state = lua.createState() try { - assertStatus(state.lua.luaL_loadstring(state.address, heapsort), 'Load raw heapsort') - assertStatus(state.lua.lua_pcallk(state.address, 0, 1, 0, 0, null), 'Compile raw heapsort') - assertStatus(state.lua.lua_pcallk(state.address, 0, 1, 0, 0, null), 'Execute raw heapsort') + assertStatus(state.module.luaL_loadstring(state.address, heapsort), 'Load raw heapsort') + assertStatus(state.module.lua_pcallk(state.address, 0, 1, 0, 0, null), 'Compile raw heapsort') + assertStatus(state.module.lua_pcallk(state.address, 0, 1, 0, 0, null), 'Execute raw heapsort') } finally { state.close() } diff --git a/package.json b/package.json index f416ecb..d46f859 100755 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "pretest": "npm run build", "test": "mocha --parallel --exit --require ./test/boot.js test/*.test.js", "luatests": "node --experimental-import-meta-resolve test/luatests.js", - "build": "rm -rf dist && rolldown -c && tsc -d --emitDeclarationOnly --rootDir src --declarationDir dist", + "build": "rm -rf dist && rolldown -c && tsc -d --emitDeclarationOnly --rootDir src --declarationDir dist && node utils/reference-types.js", "clean": "rm -rf dist build", "lint": "oxfmt --write . && oxlint --fix .", "lint:nofix": "oxfmt --check . && oxlint ." diff --git a/src/index.ts b/src/index.ts index 81cc3d4..06b2efb 100755 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export { decorate, Decoration, type DecorationOptions, type DecorationTarget, ty // use the bindings rather than the wrappers. export { default as LuaModule, + type EmscriptenFS, type EmscriptenPath, type EnvironmentVariables, type LuaEmscriptenModule, diff --git a/src/module.ts b/src/module.ts index 53414b3..e359191 100755 --- a/src/module.ts +++ b/src/module.ts @@ -1,3 +1,7 @@ +// The types below are `@types/emscripten`'s ambient globals. They only resolve for a consumer who +// lists "emscripten" in their own tsconfig `types`, so utils/reference-types.js puts a +// `/// ` at the top of the emitted declarations to cover everyone +// else. tsc drops the directive when it is written here, hence the build step. import initWasmModule from '../build/glue.js' import { defaultWarnHandler, LUA_REGISTRYINDEX, type LuaAddress, LuaReturn, LuaType, type LuaWarnHandler, PointerSize } from './types' // A rolldown plugin will resolve this to the current version on package.json @@ -15,6 +19,7 @@ export interface EmscriptenPath { join2: (left: string, right: string) => string } +/** The instantiated wasm module, as {@link LuaModule.emscripten}. */ export interface LuaEmscriptenModule extends EmscriptenModule { ccall: typeof ccall addFunction: typeof addFunction @@ -37,6 +42,9 @@ export interface LuaEmscriptenModule extends EmscriptenModule { _realloc: (pointer: number, size: number) => number } +/** The virtual filesystem every state on a runtime shares, as {@link LuaModule.emscripten}'s `FS`. */ +export type EmscriptenFS = LuaEmscriptenModule['FS'] + export interface LuaModuleOptions { /** * Where to load `glue.wasm` from. Defaults to next to this module, falling back to unpkg when @@ -57,7 +65,10 @@ export interface LuaModuleOptions { */ stdout?: ((content: string) => void) | undefined stderr?: ((content: string) => void) | undefined - /** Where load time diagnostics go. Defaults to `console.warn`. */ + /** + * Where diagnostics go, both while loading and from every state created on the runtime unless + * that state overrides it. Defaults to `console.warn`. + */ onWarn?: LuaWarnHandler | undefined } @@ -164,6 +175,7 @@ export default class LuaModule { const load = async (wasmFile?: string): Promise => { return new LuaModule( await initWasmModule({ ...(wasmFile === undefined ? {} : { locateFile: () => wasmFile }), preRun, printErr }), + opts.onWarn, ) } @@ -209,7 +221,9 @@ export default class LuaModule { } } - public _emscripten: LuaEmscriptenModule + public emscripten: LuaEmscriptenModule + /** The handler passed to {@link LuaModule.initialize}, which states created on it inherit. */ + public readonly onWarn: LuaWarnHandler | undefined public luaL_checkversion_: (L: LuaAddress, ver: number, sz: number) => void public luaL_getmetafield: (L: LuaAddress, obj: number, e: string | null) => LuaType @@ -382,8 +396,9 @@ export default class LuaModule { private readonly sizeScratch: number private stringBuffer = 0 - public constructor(module: LuaEmscriptenModule) { - this._emscripten = module + public constructor(module: LuaEmscriptenModule, onWarn?: LuaWarnHandler) { + this.emscripten = module + this.onWarn = onWarn this.luaL_checkversion_ = this.cwrap('luaL_checkversion_', null, ['number', 'number', 'number']) this.luaL_getmetafield = this.cwrap('luaL_getmetafield', 'number', ['number', 'number', 'string']) @@ -690,16 +705,16 @@ export default class LuaModule { // Never cache this: ALLOW_MEMORY_GROWTH swaps the underlying buffer when the heap grows, and // Emscripten reassigns HEAPU8 to match. private get heap(): Uint8Array { - return this._emscripten.HEAPU8 + return this.emscripten.HEAPU8 } private readSize(pointer: number): number { - return this._emscripten.HEAPU32[pointer >>> 2] + return this.emscripten.HEAPU32[pointer >>> 2] } private acquireStringBuffer(size: number): number { if (size > REUSABLE_STRING_BUFFER_LIMIT) { - const pointer = this._emscripten._malloc(size) + const pointer = this.emscripten._malloc(size) if (!pointer) { throw new Error(`failed to allocate ${size} bytes for a string`) } @@ -707,7 +722,7 @@ export default class LuaModule { } if (!this.stringBuffer) { - this.stringBuffer = this._emscripten._malloc(REUSABLE_STRING_BUFFER_LIMIT) + this.stringBuffer = this.emscripten._malloc(REUSABLE_STRING_BUFFER_LIMIT) if (!this.stringBuffer) { throw new Error(`failed to allocate ${REUSABLE_STRING_BUFFER_LIMIT} bytes for the string buffer`) } @@ -718,7 +733,7 @@ export default class LuaModule { private releaseStringBuffer(pointer: number): void { if (pointer !== this.stringBuffer) { - this._emscripten._free(pointer) + this.emscripten._free(pointer) } } @@ -731,7 +746,7 @@ export default class LuaModule { const hasStringOrNumber = argTypes.some((argType) => argType === 'string|number') if (!hasStringOrNumber) { return (...args: any[]) => - this._emscripten.ccall(name, returnType, argTypes as Emscripten.JSType[], args as Emscripten.TypeCompatibleWithC[]) + this.emscripten.ccall(name, returnType, argTypes as Emscripten.JSType[], args as Emscripten.TypeCompatibleWithC[]) } return (...args: any[]) => { @@ -743,7 +758,7 @@ export default class LuaModule { } else { // because it will be freed later, this can only be used on functions that lua internally copies the string if (args[i]?.length > 1024) { - const bufferPointer = this._emscripten.stringToNewUTF8(args[i] as string) + const bufferPointer = this.emscripten.stringToNewUTF8(args[i] as string) args[i] = bufferPointer pointersToBeFreed.push(bufferPointer) return 'number' @@ -756,10 +771,10 @@ export default class LuaModule { }) try { - return this._emscripten.ccall(name, returnType, resolvedArgTypes, args as Emscripten.TypeCompatibleWithC[]) + return this.emscripten.ccall(name, returnType, resolvedArgTypes, args as Emscripten.TypeCompatibleWithC[]) } finally { for (const pointer of pointersToBeFreed) { - this._emscripten._free(pointer) + this.emscripten._free(pointer) } } } diff --git a/src/runtime.ts b/src/runtime.ts index 30ca443..527a879 100755 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,4 +1,4 @@ -import LuaModule, { type EmscriptenPath, type LuaModuleOptions } from './module' +import LuaModule, { type EmscriptenFS, type EmscriptenPath, type LuaModuleOptions } from './module' import LuaState from './state' import type { CreateStateOptions } from './types' @@ -41,21 +41,20 @@ export default class LuaRuntime { } /** - * Mounts a file in the Lua environment synchronously. + * Writes a file into the Lua environment, creating its directories as needed. * @param path - Path to the file in the Lua environment. * @param content - Content of the file to be mounted. */ public mountFile(path: string, content: string | ArrayBufferView): void { - const dirname = this.module._emscripten.PATH.dirname(path) - this.module._emscripten.FS.mkdirTree(dirname) - this.module._emscripten.FS.writeFile(path, content) + this.filesystem.mkdirTree(this.path.dirname(path)) + this.filesystem.writeFile(path, content) } - public get filesystem(): typeof this.module._emscripten.FS { - return this.module._emscripten.FS + public get filesystem(): EmscriptenFS { + return this.module.emscripten.FS } public get path(): EmscriptenPath { - return this.module._emscripten.PATH + return this.module.emscripten.PATH } } diff --git a/src/state.ts b/src/state.ts index a55c6d9..e1fccd8 100755 --- a/src/state.ts +++ b/src/state.ts @@ -20,8 +20,8 @@ import { } from './types' /** - * Memory accounting for a state created with `{ memory: { trace: true } }`. On a state without - * tracing `state.memory` is undefined, so the absence is a type error rather than a runtime one. + * Memory accounting for a state created with `memory.trace` or `memory.max`. Without either + * `state.memory` is undefined, so the absence is a type error rather than a runtime one. */ export interface LuaMemory { /** Bytes currently allocated by this state. */ @@ -58,20 +58,18 @@ interface CreatedState { * of the constructor body rather than branching around `super()` twice. */ function createAddress(cmodule: LuaModule, memory: LuaMemoryOptions | undefined): CreatedState { - if (!memory?.trace) { - if (memory?.max !== undefined) { - throw new Error('memory.max requires memory.trace to be enabled') - } + // A cap can only be enforced by the tracing allocator, so asking for one asks for tracing. + if (!memory?.trace && memory?.max === undefined) { return { address: cmodule.luaL_newstate() } } const stats = { used: 0, max: memory.max } - const allocatorFunctionPointer = cmodule._emscripten.addFunction( + const allocatorFunctionPointer = cmodule.emscripten.addFunction( (_userData: number, pointer: number, oldSize: number, newSize: number): number => { if (newSize === 0) { if (pointer) { stats.used -= oldSize - cmodule._emscripten._free(pointer) + cmodule.emscripten._free(pointer) } return 0 } @@ -83,7 +81,7 @@ function createAddress(cmodule: LuaModule, memory: LuaMemoryOptions | undefined) return 0 } - const reallocated = cmodule._emscripten._realloc(pointer, newSize) + const reallocated = cmodule.emscripten._realloc(pointer, newSize) if (reallocated) { stats.used = endMemory } @@ -98,8 +96,14 @@ function createAddress(cmodule: LuaModule, memory: LuaMemoryOptions | undefined) ((Date.now() >>> 0) ^ Math.floor(Math.random() * 0x100000000)) >>> 0, ) if (!address) { - cmodule._emscripten.removeFunction(allocatorFunctionPointer) - throw new Error('lua_newstate returned a null pointer') + cmodule.emscripten.removeFunction(allocatorFunctionPointer) + // A cap the state cannot even be built under is the overwhelmingly likely cause, and it is + // the one thing the caller can act on. + throw new Error( + memory.max === undefined + ? 'lua_newstate returned a null pointer' + : `a memory.max of ${memory.max} bytes is too small to create a Lua state`, + ) } return { address, memory: stats, allocatorFunctionPointer } @@ -112,7 +116,7 @@ function createAddress(cmodule: LuaModule, memory: LuaMemoryOptions | undefined) * does not affect the others. */ export default class LuaState extends Thread { - /** Present only when the state was created with `{ memory: { trace: true } }`. */ + /** Present only when the state was created with `memory.trace` or `memory.max`. */ public readonly memory: LuaMemory | undefined private readonly allocatorFunctionPointer: number | undefined @@ -159,7 +163,7 @@ export default class LuaState extends Thread { const libraryMask = resolveLibraryMask(libs) if (libraryMask !== 0) { - this.lua.luaL_openselectedlibs(this.address, libraryMask, 0) + this.module.luaL_openselectedlibs(this.address, libraryMask, 0) } this.defaultMaxInstructions = limits?.maxInstructions @@ -210,7 +214,7 @@ export default class LuaState extends Thread { /** Retrieves the value of a global variable. */ public get(name: string): T { - const type = this.lua.lua_getglobal(this.address, name) + const type = this.module.lua_getglobal(this.address, name) const value = this.getValue(-1, type) this.pop() return value @@ -219,12 +223,12 @@ export default class LuaState extends Thread { /** Sets the value of a global variable. */ public set(name: string, value: unknown): void { this.pushValue(value) - this.lua.lua_setglobal(this.address, name) + this.module.lua_setglobal(this.address, name) } public getTable(name: string, callback: (index: number) => void): void { const startStackTop = this.getTop() - const type = this.lua.lua_getglobal(this.address, name) + const type = this.module.lua_getglobal(this.address, name) try { if (type !== LuaType.Table) { throw new TypeError(`Unexpected type in ${name}. Expected ${LuaType[LuaType.Table]}. Got ${LuaType[type]}.`) @@ -256,10 +260,10 @@ export default class LuaState extends Thread { // Here rather than in the threads because you don't // actually close threads, just pop them. Only the top-level // lua state needs closing. - this.lua.lua_close(this.address) + this.module.lua_close(this.address) if (this.allocatorFunctionPointer) { - this.lua._emscripten.removeFunction(this.allocatorFunctionPointer) + this.module.emscripten.removeFunction(this.allocatorFunctionPointer) } for (const wrapper of this.typeExtensions) { @@ -296,7 +300,7 @@ export default class LuaState extends Thread { private async callByteCode(loader: (thread: Thread) => void, options?: LuaRunOptions): Promise { const thread = this.newThread() // Move the thread off the global stack and into the registry as a GC anchor so it doesn't pile threads up into the stack - const ref = this.lua.luaL_ref(this.address, LUA_REGISTRYINDEX) + const ref = this.module.luaL_ref(this.address, LUA_REGISTRYINDEX) try { // Seeded from the state so a state wide setLimits reaches the async path too, which // runs on a child thread rather than on the state itself. @@ -305,7 +309,7 @@ export default class LuaState extends Thread { return (await thread.run(0, options))[0] } finally { thread.close() - this.lua.luaL_unref(this.address, LUA_REGISTRYINDEX, ref) + this.module.luaL_unref(this.address, LUA_REGISTRYINDEX, ref) } } } diff --git a/src/thread.ts b/src/thread.ts index b49c346..dcac9ee 100755 --- a/src/thread.ts +++ b/src/thread.ts @@ -40,8 +40,9 @@ const NO_RESTORE = (): void => undefined export default class Thread { public readonly address: LuaAddress - public readonly lua: LuaModule - /** Set on the root state; threads created from it delegate here. */ + /** The raw bindings, shared by every state and thread on the same runtime. */ + public readonly module: LuaModule + /** Set on the root state; threads created from it delegate here, as does {@link warn}. */ public onWarn: LuaWarnHandler | undefined protected readonly typeExtensions: OrderedExtension[] protected readonly parent: Thread | undefined @@ -51,75 +52,75 @@ export default class Thread { private limits: LuaThreadLimits = {} private instructionsUsed = 0 - public constructor(lua: LuaModule, typeExtensions: OrderedExtension[], address: number, parent?: Thread) { - this.lua = lua + public constructor(cmodule: LuaModule, typeExtensions: OrderedExtension[], address: number, parent?: Thread) { + this.module = cmodule this.typeExtensions = typeExtensions this.address = address this.parent = parent } public newThread(): Thread { - const address = this.lua.lua_newthread(this.address) + const address = this.module.lua_newthread(this.address) if (!address) { throw new Error('lua_newthread returned a null pointer') } - return new Thread(this.lua, this.typeExtensions, address, this.parent || this) + return new Thread(this.module, this.typeExtensions, address, this.parent || this) } public resetThread(): void { - this.assertOk(this.lua.lua_resetthread(this.address)) + this.assertOk(this.module.lua_resetthread(this.address)) } /** @param options.mode defaults to `'t'`. See {@link LuaLoadOptions.mode}. */ public loadString(luaCode: string, options?: LuaLoadOptions): void { - const size = this.lua._emscripten.lengthBytesUTF8(luaCode) + const size = this.module.emscripten.lengthBytesUTF8(luaCode) const pointerSize = size + 1 - const bufferPointer = this.lua._emscripten._malloc(pointerSize) + const bufferPointer = this.module.emscripten._malloc(pointerSize) try { - this.lua._emscripten.stringToUTF8(luaCode, bufferPointer, pointerSize) + this.module.emscripten.stringToUTF8(luaCode, bufferPointer, pointerSize) this.assertOk( - this.lua.luaL_loadbufferx(this.address, bufferPointer, size, options?.name ?? bufferPointer, options?.mode ?? 't'), + this.module.luaL_loadbufferx(this.address, bufferPointer, size, options?.name ?? bufferPointer, options?.mode ?? 't'), ) } finally { - this.lua._emscripten._free(bufferPointer) + this.module.emscripten._free(bufferPointer) } } /** @param options.mode defaults to `'t'`. See {@link LuaLoadOptions.mode}. */ public loadFile(filename: string, options?: LuaLoadOptions): void { - this.assertOk(this.lua.luaL_loadfilex(this.address, filename, options?.mode ?? 't')) + this.assertOk(this.module.luaL_loadfilex(this.address, filename, options?.mode ?? 't')) } public resume(argCount = 0): LuaResumeResult { - const dataPointer = this.lua._emscripten._malloc(PointerSize) + const dataPointer = this.module.emscripten._malloc(PointerSize) try { - this.lua._emscripten.setValue(dataPointer, 0, 'i32') - const luaResult = this.lua.lua_resume(this.address, null, argCount, dataPointer) + this.module.emscripten.setValue(dataPointer, 0, 'i32') + const luaResult = this.module.lua_resume(this.address, null, argCount, dataPointer) return { result: luaResult, - resultCount: this.lua._emscripten.getValue(dataPointer, 'i32'), + resultCount: this.module.emscripten.getValue(dataPointer, 'i32'), } } finally { - this.lua._emscripten._free(dataPointer) + this.module.emscripten._free(dataPointer) } } public getTop(): number { - return this.lua.lua_gettop(this.address) + return this.module.lua_gettop(this.address) } public setTop(index: number): void { - this.lua.lua_settop(this.address, index) + this.module.lua_settop(this.address, index) } public remove(index: number): void { - return this.lua.lua_remove(this.address, index) + return this.module.lua_remove(this.address, index) } public setField(index: number, name: string, value: unknown): void { - index = this.lua.lua_absindex(this.address, index) + index = this.module.lua_absindex(this.address, index) this.pushValue(value) - this.lua.lua_setfield(this.address, index, name) + this.module.lua_setfield(this.address, index, name) } public async run(argCount = 0, options?: LuaRunOptions): Promise { @@ -173,7 +174,7 @@ export default class Thread { const restore = this.applyRunOptions(options) try { const base = this.getTop() - argCount - 1 // The 1 is for the function to run - this.assertOk(this.lua.lua_pcallk(this.address, argCount, LUA_MULTRET, 0, 0, null)) + this.assertOk(this.module.lua_pcallk(this.address, argCount, LUA_MULTRET, 0, 0, null)) return this.getStackValues(base) } finally { restore() @@ -181,13 +182,13 @@ export default class Thread { } public pop(count = 1): void { - this.lua.lua_pop(this.address, count) + this.module.lua_pop(this.address, count) } public call(name: string, ...args: any[]): MultiReturn { - const type = this.lua.lua_getglobal(this.address, name) + const type = this.module.lua_getglobal(this.address, name) if (type !== LuaType.Function) { - throw new Error(`A function of type '${type}' was pushed, expected is ${LuaType.Function}`) + throw new TypeError(`cannot call '${name}': expected a function, got ${LuaType[type]}`) } for (const arg of args) { @@ -195,7 +196,7 @@ export default class Thread { } const base = this.getTop() - args.length - 1 // The 1 is for the function to run - this.lua.lua_callk(this.address, args.length, LUA_MULTRET, 0, null) + this.module.lua_callk(this.address, args.length, LUA_MULTRET, 0, null) return this.getStackValues(base) } @@ -211,7 +212,7 @@ export default class Thread { } public stateToThread(L: LuaAddress): Thread { - return L === this.parent?.address ? this.parent : new Thread(this.lua, this.typeExtensions, L, this.parent || this) + return L === this.parent?.address ? this.parent : new Thread(this.module, this.typeExtensions, L, this.parent || this) } public pushValue(rawValue: unknown, cache?: LuaPushCache): void { @@ -219,9 +220,9 @@ export default class Thread { const target = decoratedValue.target if (target instanceof Thread) { - const isMain = this.lua.lua_pushthread(target.address) === 1 + const isMain = this.module.lua_pushthread(target.address) === 1 if (!isMain) { - this.lua.lua_xmove(target.address, this.address, 1) + this.module.lua_xmove(target.address, this.address, 1) } return } @@ -231,35 +232,35 @@ export default class Thread { // Handle primitive types switch (typeof target) { case 'undefined': - this.lua.lua_pushnil(this.address) + this.module.lua_pushnil(this.address) break case 'number': // Only integers JS can represent exactly become Lua integers. Values like 1e300 // are integral but far outside int64, and would wrap silently if pushed as one. if (Number.isSafeInteger(target)) { - this.lua.lua_pushinteger(this.address, BigInt(target)) + this.module.lua_pushinteger(this.address, BigInt(target)) } else { - this.lua.lua_pushnumber(this.address, target) + this.module.lua_pushnumber(this.address, target) } break case 'bigint': if (BigInt.asIntN(LUA_INTEGER_BITS, target) !== target) { throw new RangeError(`bigint ${target} does not fit in a 64 bit Lua integer`) } - this.lua.lua_pushinteger(this.address, target) + this.module.lua_pushinteger(this.address, target) break case 'string': - this.lua.lua_pushstring(this.address, target) + this.module.lua_pushstring(this.address, target) break case 'boolean': - this.lua.lua_pushboolean(this.address, target ? 1 : 0) + this.module.lua_pushboolean(this.address, target ? 1 : 0) break default: if (this.typeExtensions.find((wrapper) => wrapper.extension.pushValue(this, decoratedValue, cache))) { break } if (target === null) { - this.lua.lua_pushnil(this.address) + this.module.lua_pushnil(this.address) break } throw new Error(`The type '${typeof target}' is not supported by Lua`) @@ -275,20 +276,20 @@ export default class Thread { } public setMetatable(index: number, metatable: LuaMetatable): void { - index = this.lua.lua_absindex(this.address, index) + index = this.module.lua_absindex(this.address, index) - if (this.lua.lua_getmetatable(this.address, index)) { + if (this.module.lua_getmetatable(this.address, index)) { this.pop(1) const name = this.getMetatableName(index) throw new Error(`data already has associated metatable: ${name || 'unknown name'}`) } this.pushValue(metatable) - this.lua.lua_setmetatable(this.address, index) + this.module.lua_setmetatable(this.address, index) } public getMetatableName(index: number): string | undefined { - const metatableNameType = this.lua.luaL_getmetafield(this.address, index, '__name') + const metatableNameType = this.module.luaL_getmetafield(this.address, index, '__name') if (metatableNameType === LuaType.Nil) { return undefined } @@ -299,7 +300,7 @@ export default class Thread { return undefined } - const name = this.lua.lua_tolstring(this.address, -1, null) + const name = this.module.lua_tolstring(this.address, -1, null) // This is popping the luaL_getmetafield result which only pushes with type is not nil. this.pop(1) @@ -307,9 +308,9 @@ export default class Thread { } public getValue(index: number, inputType?: LuaType, cache?: LuaGetCache): any { - index = this.lua.lua_absindex(this.address, index) + index = this.module.lua_absindex(this.address, index) - const type: LuaType = inputType ?? this.lua.lua_type(this.address, index) + const type: LuaType = inputType ?? this.module.lua_type(this.address, index) switch (type) { case LuaType.None: @@ -317,20 +318,20 @@ export default class Thread { case LuaType.Nil: return null case LuaType.Number: { - const value = this.lua.lua_tonumberx(this.address, index, null) + const value = this.module.lua_tonumberx(this.address, index, null) // Only outside the safe range can the integer subtype change the result, and // checking that first keeps the common case to a single wasm call. - if (Number.isSafeInteger(value) || !this.lua.lua_isinteger(this.address, index)) { + if (Number.isSafeInteger(value) || !this.module.lua_isinteger(this.address, index)) { return value } - return this.lua.lua_tointegerx(this.address, index, null) + return this.module.lua_tointegerx(this.address, index, null) } case LuaType.String: - return this.lua.lua_tolstring(this.address, index, null) + return this.module.lua_tolstring(this.address, index, null) case LuaType.Boolean: - return Boolean(this.lua.lua_toboolean(this.address, index)) + return Boolean(this.module.lua_toboolean(this.address, index)) case LuaType.Thread: - return this.stateToThread(this.lua.lua_tothread(this.address, index)) + return this.stateToThread(this.module.lua_tothread(this.address, index)) default: { let metatableName: string | undefined if (type === LuaType.Table || type === LuaType.Userdata) { @@ -346,7 +347,7 @@ export default class Thread { // Handing back an opaque Pointer hid the failure until the value was used, and // it could not be pushed back into Lua anyway. - const typeName = this.lua.lua_typename(this.address, type) + const typeName = this.module.lua_typename(this.address, type) const withMetatable = metatableName ? ` with metatable '${metatableName}'` : '' throw new TypeError( `the Lua type '${typeName}'${withMetatable} has no JS representation; register a type ` + @@ -362,7 +363,7 @@ export default class Thread { } if (this.hookFunctionPointer) { - this.lua._emscripten.removeFunction(this.hookFunctionPointer) + this.module.emscripten.removeFunction(this.hookFunctionPointer) this.hookFunctionPointer = undefined } @@ -401,15 +402,19 @@ export default class Thread { return this.limits.deadline } - /** Diagnostics the library would otherwise have written straight to the console. */ + /** + * Diagnostics the library would otherwise have written straight to the console. Resolved on + * every call rather than snapshotted, so clearing a state's handler falls back to the + * runtime's rather than straight to the console. + */ public warn(message: string, cause?: unknown): void { const root = this.parent ?? this - ;(root.onWarn ?? defaultWarnHandler)(message, cause) + ;(root.onWarn ?? this.module.onWarn ?? defaultWarnHandler)(message, cause) } /** For identity checks on values JS cannot represent. */ public getPointer(index: number): LuaAddress { - return this.lua.lua_topointer(this.address, index) + return this.module.lua_topointer(this.address, index) } public isClosed(): boolean { @@ -417,7 +422,7 @@ export default class Thread { } public indexToString(index: number): string { - const str = this.lua.luaL_tolstring(this.address, index, null) + const str = this.module.luaL_tolstring(this.address, index, null) // Pops the string pushed by luaL_tolstring this.pop() return str @@ -430,19 +435,19 @@ export default class Thread { * @returns the bytes, or undefined if the value is neither a string nor a number. */ public getStringBytes(index: number): Uint8Array | undefined { - return this.lua.lua_tobytes(this.address, index) + return this.module.lua_tobytes(this.address, index) } public pushStringBytes(bytes: Uint8Array): void { - this.lua.lua_pushbytes(this.address, bytes) + this.module.lua_pushbytes(this.address, bytes) } public dumpStack(log = console.log): void { const top = this.getTop() for (let i = 1; i <= top; i++) { - const type = this.lua.lua_type(this.address, i) - const typename = this.lua.lua_typename(this.address, type) + const type = this.module.lua_type(this.address, i) + const typename = this.module.lua_typename(this.address, type) const pointer = this.getPointer(i) const name = this.indexToString(i) let value: unknown @@ -469,7 +474,7 @@ export default class Thread { if (this.getTop() > 0) { if (result === LuaReturn.ErrorMem) { // If there's no memory just do a normal to string. - luaMessage = this.lua.lua_tolstring(this.address, -1, null) + luaMessage = this.module.lua_tolstring(this.address, -1, null) } else { try { luaValue = this.getValue(-1) @@ -490,8 +495,8 @@ export default class Thread { let traceback: string | undefined if (result !== LuaReturn.ErrorMem) { try { - this.lua.luaL_traceback(this.address, this.address, null, 1) - const text = this.lua.lua_tolstring(this.address, -1, null) + this.module.luaL_traceback(this.address, this.address, null, 1) + const text = this.module.lua_tolstring(this.address, -1, null) if (text.trim() !== 'stack traceback:') { traceback = text } @@ -535,7 +540,7 @@ export default class Thread { private applyHook(): void { const { deadline, maxInstructions, signal } = this.limits if (deadline === undefined && maxInstructions === undefined && signal === undefined) { - this.lua.lua_sethook(this.address, null, 0, 0) + this.module.lua_sethook(this.address, null, 0, 0) return } @@ -544,18 +549,18 @@ export default class Thread { maxInstructions !== undefined ? Math.max(1, Math.min(INSTRUCTION_HOOK_COUNT, maxInstructions)) : INSTRUCTION_HOOK_COUNT if (!this.hookFunctionPointer) { - this.hookFunctionPointer = this.lua._emscripten.addFunction((): void => { + this.hookFunctionPointer = this.module.emscripten.addFunction((): void => { // Reads this.limits rather than closing over them, so a hook allocated for an // earlier configuration still honours the current one. const error = this.checkHookLimits() if (error) { this.pushValue(error) - this.lua.lua_error(this.address) + this.module.lua_error(this.address) } }, 'vii') } - this.lua.lua_sethook(this.address, this.hookFunctionPointer, LuaEventMasks.Count, this.hookCount) + this.module.lua_sethook(this.address, this.hookFunctionPointer, LuaEventMasks.Count, this.hookCount) } private checkHookLimits(): Error | undefined { diff --git a/src/type-extension.ts b/src/type-extension.ts index 95c1940..580bbf6 100644 --- a/src/type-extension.ts +++ b/src/type-extension.ts @@ -25,11 +25,11 @@ export default abstract class LuaTypeExtension { * pointer and has to release it with `removeFunction` in {@link close}. */ protected createGcFunction(): number { - return this.state.lua._emscripten.addFunction((calledL: LuaAddress) => { + return this.state.module.emscripten.addFunction((calledL: LuaAddress) => { // Throws a lua error which does a jump if it does not match. - const userDataPointer = this.state.lua.luaL_checkudata(calledL, 1, this.name) - const referencePointer = this.state.lua._emscripten.getValue(userDataPointer, '*') - this.state.lua.unref(referencePointer) + const userDataPointer = this.state.module.luaL_checkudata(calledL, 1, this.name) + const referencePointer = this.state.module.emscripten.getValue(userDataPointer, '*') + this.state.module.unref(referencePointer) return LuaReturn.Ok }, 'ii') @@ -37,12 +37,12 @@ export default abstract class LuaTypeExtension { // A base implementation that assumes user data serialisation public getValue(thread: Thread, index: number, _cache?: LuaGetCache): T { - const refUserdata = thread.lua.luaL_testudata(thread.address, index, this.name) + const refUserdata = thread.module.luaL_testudata(thread.address, index, this.name) if (!refUserdata) { throw new Error(`data does not have the expected metatable: ${this.name}`) } - const referencePointer = thread.lua._emscripten.getValue(refUserdata, '*') - return thread.lua.getRef(referencePointer) as T + const referencePointer = thread.module.emscripten.getValue(refUserdata, '*') + return thread.module.getRef(referencePointer) as T } // Return false if type not matched, otherwise true. This base method does not @@ -50,12 +50,12 @@ export default abstract class LuaTypeExtension { public pushValue(thread: Thread, decoratedValue: Decoration, _cache?: LuaPushCache): boolean { const { target } = decoratedValue - const pointer = thread.lua.ref(target) + const pointer = thread.module.ref(target) // 4 = size of pointer in wasm. - const userDataPointer = thread.lua.lua_newuserdatauv(thread.address, PointerSize, 0) - thread.lua._emscripten.setValue(userDataPointer, pointer, '*') + const userDataPointer = thread.module.lua_newuserdatauv(thread.address, PointerSize, 0) + thread.module.emscripten.setValue(userDataPointer, pointer, '*') - if (LuaType.Nil === thread.lua.luaL_getmetatable(thread.address, this.name)) { + if (LuaType.Nil === thread.module.luaL_getmetatable(thread.address, this.name)) { // Pop the pushed nil value and the user data. Don't need to unref because it's // already associated with the user data pointer. thread.pop(2) @@ -64,7 +64,7 @@ export default abstract class LuaTypeExtension { // Set as the metatable for the userdata. // -1 is the metatable, -2 is the user data. - thread.lua.lua_setmetatable(thread.address, -2) + thread.module.lua_setmetatable(thread.address, -2) return true } diff --git a/src/type-extensions/error.ts b/src/type-extensions/error.ts index 770ea15..82a374f 100644 --- a/src/type-extensions/error.ts +++ b/src/type-extensions/error.ts @@ -11,16 +11,16 @@ class ErrorTypeExtension extends TypeExtension { this.gcPointer = this.createGcFunction() - if (state.lua.luaL_newmetatable(state.address, this.name)) { - const metatableIndex = state.lua.lua_gettop(state.address) + if (state.module.luaL_newmetatable(state.address, this.name)) { + const metatableIndex = state.module.lua_gettop(state.address) // Mark it as uneditable - state.lua.lua_pushstring(state.address, 'protected metatable') - state.lua.lua_setfield(state.address, metatableIndex, '__metatable') + state.module.lua_pushstring(state.address, 'protected metatable') + state.module.lua_setfield(state.address, metatableIndex, '__metatable') // Add the gc function - state.lua.lua_pushcclosure(state.address, this.gcPointer, 0) - state.lua.lua_setfield(state.address, metatableIndex, '__gc') + state.module.lua_pushcclosure(state.address, this.gcPointer, 0) + state.module.lua_setfield(state.address, metatableIndex, '__gc') // Add an __index method that returns the message field state.pushValue((jsRefError: Error, key: unknown) => { @@ -29,7 +29,7 @@ class ErrorTypeExtension extends TypeExtension { } return null }) - state.lua.lua_setfield(state.address, metatableIndex, '__index') + state.module.lua_setfield(state.address, metatableIndex, '__index') // Add a tostring method that returns the message. state.pushValue((jsRefError: Error) => { @@ -37,10 +37,10 @@ class ErrorTypeExtension extends TypeExtension { // added. This fits better with Lua errors. return jsRefError.message }) - state.lua.lua_setfield(state.address, metatableIndex, '__tostring') + state.module.lua_setfield(state.address, metatableIndex, '__tostring') } // Pop the metatable from the stack. - state.lua.lua_pop(state.address, 1) + state.module.lua_pop(state.address, 1) if (injectObject) { // Lastly create a static Error constructor. @@ -68,7 +68,7 @@ class ErrorTypeExtension extends TypeExtension { } public close(): void { - this.state.lua._emscripten.removeFunction(this.gcPointer) + this.state.module.emscripten.removeFunction(this.gcPointer) } } diff --git a/src/type-extensions/function.ts b/src/type-extensions/function.ts index 1ed4a57..f7f0919 100644 --- a/src/type-extensions/function.ts +++ b/src/type-extensions/function.ts @@ -12,7 +12,7 @@ export type FunctionType = (...args: any[]) => Promise | any class FunctionTypeExtension extends TypeExtension { private readonly functionRegistry = new FinalizationRegistry((func: number) => { if (!this.state.isClosed()) { - this.state.lua.luaL_unref(this.state.address, LUA_REGISTRYINDEX, func) + this.state.module.luaL_unref(this.state.address, LUA_REGISTRYINDEX, func) } }) @@ -32,7 +32,7 @@ class FunctionTypeExtension extends TypeExtension { // even if the thread that called getValue() has been destroyed. this.callbackContext = state.newThread() // Pops it from the global stack but keeps it alive - this.callbackContextIndex = this.state.lua.luaL_ref(state.address, LUA_REGISTRYINDEX) + this.callbackContextIndex = this.state.module.luaL_ref(state.address, LUA_REGISTRYINDEX) if (!this.functionRegistry) { state.warn('FunctionTypeExtension: FinalizationRegistry not found. Memory leaks likely.') @@ -41,24 +41,24 @@ class FunctionTypeExtension extends TypeExtension { this.gcPointer = this.createGcFunction() // Creates metatable if it doesn't exist, always pushes it onto the stack. - if (state.lua.luaL_newmetatable(state.address, this.name)) { - state.lua.lua_pushstring(state.address, '__gc') - state.lua.lua_pushcclosure(state.address, this.gcPointer, 0) - state.lua.lua_settable(state.address, -3) - - state.lua.lua_pushstring(state.address, '__metatable') - state.lua.lua_pushstring(state.address, 'protected metatable') - state.lua.lua_settable(state.address, -3) + if (state.module.luaL_newmetatable(state.address, this.name)) { + state.module.lua_pushstring(state.address, '__gc') + state.module.lua_pushcclosure(state.address, this.gcPointer, 0) + state.module.lua_settable(state.address, -3) + + state.module.lua_pushstring(state.address, '__metatable') + state.module.lua_pushstring(state.address, 'protected metatable') + state.module.lua_settable(state.address, -3) } // Pop the metatable from the stack. - state.lua.lua_pop(state.address, 1) + state.module.lua_pop(state.address, 1) - this.functionWrapper = state.lua._emscripten.addFunction((calledL: LuaAddress) => { + this.functionWrapper = state.module.emscripten.addFunction((calledL: LuaAddress) => { const calledThread = state.stateToThread(calledL) - const refUserdata = state.lua.luaL_checkudata(calledL, state.lua.lua_upvalueindex(1), this.name) - const refPointer = state.lua._emscripten.getValue(refUserdata, '*') - const { target, options: decorationOptions } = state.lua.getRef(refPointer) as Decoration + const refUserdata = state.module.luaL_checkudata(calledL, state.module.lua_upvalueindex(1), this.name) + const refPointer = state.module.emscripten.getValue(refUserdata, '*') + const { target, options: decorationOptions } = state.module.getRef(refPointer) as Decoration const argsQuantity = calledThread.getTop() const args = [] @@ -99,18 +99,18 @@ class FunctionTypeExtension extends TypeExtension { throw err } calledThread.pushValue(err) - return calledThread.lua.lua_error(calledThread.address) + return calledThread.module.lua_error(calledThread.address) } }, 'ii') } public close(): void { - this.state.lua._emscripten.removeFunction(this.gcPointer) - this.state.lua._emscripten.removeFunction(this.functionWrapper) + this.state.module.emscripten.removeFunction(this.gcPointer) + this.state.module.emscripten.removeFunction(this.functionWrapper) // Doesn't destroy the Lua thread, just function pointers. this.callbackContext.close() // Destroy the Lua thread - this.callbackContext.lua.luaL_unref(this.callbackContext.address, LUA_REGISTRYINDEX, this.callbackContextIndex) + this.callbackContext.module.luaL_unref(this.callbackContext.address, LUA_REGISTRYINDEX, this.callbackContextIndex) } public isType(_thread: Thread, _index: number, type: LuaType): boolean { @@ -126,33 +126,33 @@ class FunctionTypeExtension extends TypeExtension { // function which stays solely in JS. The cfunction called from Lua is created at the top of the class // and it accesses the JS data through an upvalue. - const pointer = thread.lua.ref(decoration) + const pointer = thread.module.ref(decoration) // 4 = size of pointer in wasm. - const userDataPointer = thread.lua.lua_newuserdatauv(thread.address, PointerSize, 0) - thread.lua._emscripten.setValue(userDataPointer, pointer, '*') + const userDataPointer = thread.module.lua_newuserdatauv(thread.address, PointerSize, 0) + thread.module.emscripten.setValue(userDataPointer, pointer, '*') - if (LuaType.Nil === thread.lua.luaL_getmetatable(thread.address, this.name)) { + if (LuaType.Nil === thread.module.luaL_getmetatable(thread.address, this.name)) { // Pop the pushed userdata. thread.pop(1) - thread.lua.unref(pointer) + thread.module.unref(pointer) throw new Error(`metatable not found: ${this.name}`) } // Set as the metatable for the function. // -1 is the metatable, -2 is the userdata - thread.lua.lua_setmetatable(thread.address, -2) + thread.module.lua_setmetatable(thread.address, -2) // Pass 1 to associate the closure with the userdata, pops the userdata. - thread.lua.lua_pushcclosure(thread.address, this.functionWrapper, 1) + thread.module.lua_pushcclosure(thread.address, this.functionWrapper, 1) return true } public getValue(thread: Thread, index: number): FunctionType { // Create a copy of the function - thread.lua.lua_pushvalue(thread.address, index) + thread.module.lua_pushvalue(thread.address, index) // Create a reference to the function which pops it from the stack - const func = thread.lua.luaL_ref(thread.address, LUA_REGISTRYINDEX) + const func = thread.module.luaL_ref(thread.address, LUA_REGISTRYINDEX) const jsFunc = (...args: any[]): any => { // Calling a function would ideally be in the Lua context that's calling it. For example if the JS function @@ -170,12 +170,12 @@ class FunctionTypeExtension extends TypeExtension { // they can be left in inconsistent states. const callThread = this.callbackContext.newThread() try { - const internalType = callThread.lua.lua_rawgeti(callThread.address, LUA_REGISTRYINDEX, BigInt(func)) + const internalType = callThread.module.lua_rawgeti(callThread.address, LUA_REGISTRYINDEX, BigInt(func)) if (internalType !== LuaType.Function) { - const callMetafieldType = callThread.lua.luaL_getmetafield(callThread.address, -1, '__call') + const callMetafieldType = callThread.module.luaL_getmetafield(callThread.address, -1, '__call') callThread.pop() if (callMetafieldType !== LuaType.Function) { - throw new Error(`A value of type '${internalType}' was pushed but it is not callable`) + throw new TypeError(`cannot call a value of type ${LuaType[internalType]}: it has no __call metamethod`) } } @@ -187,7 +187,7 @@ class FunctionTypeExtension extends TypeExtension { callThread.setDeadline(Date.now() + this.functionTimeout) } - const status = callThread.lua.lua_pcallk(callThread.address, args.length, 1, 0, 0, null) + const status = callThread.module.lua_pcallk(callThread.address, args.length, 1, 0, 0, null) if (status === LuaReturn.Yield) { throw new Error('cannot yield in callbacks from javascript') } diff --git a/src/type-extensions/null.ts b/src/type-extensions/null.ts index d6f9c34..ea3453c 100644 --- a/src/type-extensions/null.ts +++ b/src/type-extensions/null.ts @@ -13,29 +13,29 @@ class NullTypeExtension extends TypeExtension { this.gcPointer = this.createGcFunction() - if (state.lua.luaL_newmetatable(state.address, this.name)) { - const metatableIndex = state.lua.lua_gettop(state.address) + if (state.module.luaL_newmetatable(state.address, this.name)) { + const metatableIndex = state.module.lua_gettop(state.address) // Mark it as uneditable - state.lua.lua_pushstring(state.address, 'protected metatable') - state.lua.lua_setfield(state.address, metatableIndex, '__metatable') + state.module.lua_pushstring(state.address, 'protected metatable') + state.module.lua_setfield(state.address, metatableIndex, '__metatable') // Add the gc function - state.lua.lua_pushcclosure(state.address, this.gcPointer, 0) - state.lua.lua_setfield(state.address, metatableIndex, '__gc') + state.module.lua_pushcclosure(state.address, this.gcPointer, 0) + state.module.lua_setfield(state.address, metatableIndex, '__gc') // Add an __index method that returns nothing. state.pushValue(() => null) - state.lua.lua_setfield(state.address, metatableIndex, '__index') + state.module.lua_setfield(state.address, metatableIndex, '__index') state.pushValue(() => 'null') - state.lua.lua_setfield(state.address, metatableIndex, '__tostring') + state.module.lua_setfield(state.address, metatableIndex, '__tostring') state.pushValue((self: unknown, other: unknown) => self === other) - state.lua.lua_setfield(state.address, metatableIndex, '__eq') + state.module.lua_setfield(state.address, metatableIndex, '__eq') } // Pop the metatable from the stack. - state.lua.lua_pop(state.address, 1) + state.module.lua_pop(state.address, 1) // Create a new table, this is unique and will be the "null" value by attaching the // metatable created above. The first argument is the target, the second options. @@ -43,14 +43,14 @@ class NullTypeExtension extends TypeExtension { // Lua code is free to reassign the `null` global, so marshalling anchors the sentinel in // the registry instead of looking it up by name. - state.lua.lua_pushvalue(state.address, -1) - this.nullReference = state.lua.luaL_ref(state.address, LUA_REGISTRYINDEX) + state.module.lua_pushvalue(state.address, -1) + this.nullReference = state.module.luaL_ref(state.address, LUA_REGISTRYINDEX) - state.lua.lua_setglobal(state.address, 'null') + state.module.lua_setglobal(state.address, 'null') } public getValue(thread: Thread, index: number): null { - const refUserData = thread.lua.luaL_testudata(thread.address, index, this.name) + const refUserData = thread.module.luaL_testudata(thread.address, index, this.name) if (!refUserData) { throw new Error(`data does not have the expected metatable: ${this.name}`) } @@ -61,12 +61,12 @@ class NullTypeExtension extends TypeExtension { if (decoration.target !== null) { return false } - thread.lua.lua_rawgeti(thread.address, LUA_REGISTRYINDEX, BigInt(this.nullReference)) + thread.module.lua_rawgeti(thread.address, LUA_REGISTRYINDEX, BigInt(this.nullReference)) return true } public close(): void { - this.state.lua._emscripten.removeFunction(this.gcPointer) + this.state.module.emscripten.removeFunction(this.gcPointer) } } diff --git a/src/type-extensions/promise.ts b/src/type-extensions/promise.ts index ad393bd..19b4605 100644 --- a/src/type-extensions/promise.ts +++ b/src/type-extensions/promise.ts @@ -16,16 +16,16 @@ class PromiseTypeExtension extends TypeExtension> { this.gcPointer = this.createGcFunction() - if (state.lua.luaL_newmetatable(state.address, this.name)) { - const metatableIndex = state.lua.lua_gettop(state.address) + if (state.module.luaL_newmetatable(state.address, this.name)) { + const metatableIndex = state.module.lua_gettop(state.address) // Mark it as uneditable - state.lua.lua_pushstring(state.address, 'protected metatable') - state.lua.lua_setfield(state.address, metatableIndex, '__metatable') + state.module.lua_pushstring(state.address, 'protected metatable') + state.module.lua_setfield(state.address, metatableIndex, '__metatable') // Add the gc function - state.lua.lua_pushcclosure(state.address, this.gcPointer, 0) - state.lua.lua_setfield(state.address, metatableIndex, '__gc') + state.module.lua_pushcclosure(state.address, this.gcPointer, 0) + state.module.lua_setfield(state.address, metatableIndex, '__gc') // A bare thenable reaches here too, and only `then` is guaranteed on one. Adopting it // into a real promise is what makes catch/finally/await work on it, and is a no-op for @@ -47,7 +47,7 @@ class PromiseTypeExtension extends TypeExtension> { // Asking Lua covers every non-resumable context, not just the main // thread: anything entered through lua_pcall cannot yield either. - if (!state.lua.lua_isyieldable(functionThread.address)) { + if (!state.module.lua_isyieldable(functionThread.address)) { throw new Error('cannot await in a thread that cannot yield, use doString instead of doStringSync') } @@ -62,7 +62,7 @@ class PromiseTypeExtension extends TypeExtension> { promiseResult = { status: 'rejected', value: err } }) - const continuance = this.state.lua._emscripten.addFunction((continuanceState: LuaAddress) => { + const continuance = this.state.module.emscripten.addFunction((continuanceState: LuaAddress) => { // If this yield has been called from within a coroutine and so manually resumed // then there may not yet be any results. In that case yield again. if (!promiseResult) { @@ -71,16 +71,16 @@ class PromiseTypeExtension extends TypeExtension> { // 0 because this is called between resumes so the first one should've // popped the promise before returning the result. This is true within // Lua's coroutine.resume too. - return state.lua.lua_yieldk(functionThread.address, 0, 0, continuance) + return state.module.lua_yieldk(functionThread.address, 0, 0, continuance) } - this.state.lua._emscripten.removeFunction(continuance) + this.state.module.emscripten.removeFunction(continuance) const continuanceThread = state.stateToThread(continuanceState) if (promiseResult.status === 'rejected') { continuanceThread.pushValue(promiseResult.value || new Error('promise rejected with no error')) - return this.state.lua.lua_error(continuanceState) + return this.state.module.lua_error(continuanceState) } if (promiseResult.value instanceof RawResult) { @@ -97,18 +97,18 @@ class PromiseTypeExtension extends TypeExtension> { }, 'iiii') functionThread.pushValue(awaitPromise) - return new RawResult(state.lua.lua_yieldk(functionThread.address, 1, 0, continuance)) + return new RawResult(state.module.lua_yieldk(functionThread.address, 1, 0, continuance)) }, { receiveThread: true }, ), }) - state.lua.lua_setfield(state.address, metatableIndex, '__index') + state.module.lua_setfield(state.address, metatableIndex, '__index') state.pushValue((self: Promise, other: Promise) => self === other) - state.lua.lua_setfield(state.address, metatableIndex, '__eq') + state.module.lua_setfield(state.address, metatableIndex, '__eq') } // Pop the metatable from the stack. - state.lua.lua_pop(state.address, 1) + state.module.lua_pop(state.address, 1) if (injectObject) { // Lastly create a static Promise constructor. @@ -127,7 +127,7 @@ class PromiseTypeExtension extends TypeExtension> { } public close(): void { - this.state.lua._emscripten.removeFunction(this.gcPointer) + this.state.module.emscripten.removeFunction(this.gcPointer) } public pushValue(thread: Thread, decoration: Decoration): boolean { diff --git a/src/type-extensions/proxy.ts b/src/type-extensions/proxy.ts index a059d18..2346aaa 100644 --- a/src/type-extensions/proxy.ts +++ b/src/type-extensions/proxy.ts @@ -14,16 +14,16 @@ class ProxyTypeExtension extends TypeExtension { this.gcPointer = this.createGcFunction() - if (state.lua.luaL_newmetatable(state.address, this.name)) { - const metatableIndex = state.lua.lua_gettop(state.address) + if (state.module.luaL_newmetatable(state.address, this.name)) { + const metatableIndex = state.module.lua_gettop(state.address) // Mark it as uneditable - state.lua.lua_pushstring(state.address, 'protected metatable') - state.lua.lua_setfield(state.address, metatableIndex, '__metatable') + state.module.lua_pushstring(state.address, 'protected metatable') + state.module.lua_setfield(state.address, metatableIndex, '__metatable') // Add the gc function - state.lua.lua_pushcclosure(state.address, this.gcPointer, 0) - state.lua.lua_setfield(state.address, metatableIndex, '__gc') + state.module.lua_pushcclosure(state.address, this.gcPointer, 0) + state.module.lua_setfield(state.address, metatableIndex, '__gc') state.pushValue((self: any, key: unknown) => { switch (typeof key) { @@ -46,7 +46,7 @@ class ProxyTypeExtension extends TypeExtension { return value }) - state.lua.lua_setfield(state.address, metatableIndex, '__index') + state.module.lua_setfield(state.address, metatableIndex, '__index') state.pushValue((self: any, key: unknown, value: any) => { switch (typeof key) { @@ -61,17 +61,17 @@ class ProxyTypeExtension extends TypeExtension { } self[key as string | number] = value }) - state.lua.lua_setfield(state.address, metatableIndex, '__newindex') + state.module.lua_setfield(state.address, metatableIndex, '__newindex') state.pushValue((self: any) => { return self.toString?.() ?? typeof self }) - state.lua.lua_setfield(state.address, metatableIndex, '__tostring') + state.module.lua_setfield(state.address, metatableIndex, '__tostring') state.pushValue((self: any) => { return self.length || 0 }) - state.lua.lua_setfield(state.address, metatableIndex, '__len') + state.module.lua_setfield(state.address, metatableIndex, '__len') state.pushValue((self: any) => { const keys = Object.getOwnPropertyNames(self) @@ -87,12 +87,12 @@ class ProxyTypeExtension extends TypeExtension { null, ) }) - state.lua.lua_setfield(state.address, metatableIndex, '__pairs') + state.module.lua_setfield(state.address, metatableIndex, '__pairs') state.pushValue((self: any, other: any) => { return self === other }) - state.lua.lua_setfield(state.address, metatableIndex, '__eq') + state.module.lua_setfield(state.address, metatableIndex, '__eq') state.pushValue((self: any, ...args: any[]) => { if (args[0] === self) { @@ -100,11 +100,11 @@ class ProxyTypeExtension extends TypeExtension { } return self(...args) }) - state.lua.lua_setfield(state.address, metatableIndex, '__call') + state.module.lua_setfield(state.address, metatableIndex, '__call') } // Pop the metatable from the stack. - state.lua.lua_pop(state.address, 1) + state.module.lua_pop(state.address, 1) } public isType(_thread: Thread, _index: number, type: LuaType, name?: string): boolean { @@ -113,9 +113,9 @@ class ProxyTypeExtension extends TypeExtension { } public getValue(thread: Thread, index: number): any { - const refUserdata = thread.lua.lua_touserdata(thread.address, index) - const referencePointer = thread.lua._emscripten.getValue(refUserdata, '*') - return thread.lua.getRef(referencePointer) + const refUserdata = thread.module.lua_touserdata(thread.address, index) + const referencePointer = thread.module.emscripten.getValue(refUserdata, '*') + return thread.module.getRef(referencePointer) } public pushValue(thread: Thread, decoratedValue: Decoration): boolean { @@ -153,7 +153,7 @@ class ProxyTypeExtension extends TypeExtension { } public close(): void { - this.state.lua._emscripten.removeFunction(this.gcPointer) + this.state.module.emscripten.removeFunction(this.gcPointer) } } diff --git a/src/type-extensions/table.ts b/src/type-extensions/table.ts index 2501735..3564020 100644 --- a/src/type-extensions/table.ts +++ b/src/type-extensions/table.ts @@ -22,7 +22,7 @@ class TableTypeExtension extends TypeExtension { public getValue(thread: Thread, index: number, cache?: LuaGetCache): TableType { // This is a map of Lua pointers to JS objects. const seenMap: LuaGetCache = cache ?? new Map() - const pointer = thread.lua.lua_topointer(thread.address, index) + const pointer = thread.module.lua_topointer(thread.address, index) let table = seenMap.get(pointer) as TableType | undefined if (!table) { @@ -47,7 +47,7 @@ class TableTypeExtension extends TypeExtension { const seenMap: LuaPushCache = cache ?? new Map() const existingReference = seenMap.get(target) if (existingReference !== undefined) { - thread.lua.lua_rawgeti(thread.address, LUA_REGISTRYINDEX, BigInt(existingReference)) + thread.module.lua_rawgeti(thread.address, LUA_REGISTRYINDEX, BigInt(existingReference)) return true } @@ -55,10 +55,10 @@ class TableTypeExtension extends TypeExtension { const tableIndex = thread.getTop() + 1 const createTable = (arrayCount: number, keyCount: number): void => { - thread.lua.lua_createtable(thread.address, arrayCount, keyCount) - const ref = thread.lua.luaL_ref(thread.address, LUA_REGISTRYINDEX) + thread.module.lua_createtable(thread.address, arrayCount, keyCount) + const ref = thread.module.luaL_ref(thread.address, LUA_REGISTRYINDEX) seenMap.set(target, ref) - thread.lua.lua_rawgeti(thread.address, LUA_REGISTRYINDEX, BigInt(ref)) + thread.module.lua_rawgeti(thread.address, LUA_REGISTRYINDEX, BigInt(ref)) } if (Array.isArray(target)) { @@ -67,7 +67,7 @@ class TableTypeExtension extends TypeExtension { for (let i = 0; i < target.length; i++) { thread.pushValue(target[i], seenMap) // Raw, so the table being built cannot be observed through metamethods. - thread.lua.lua_rawseti(thread.address, tableIndex, BigInt(i + 1)) + thread.module.lua_rawseti(thread.address, tableIndex, BigInt(i + 1)) } } else { // A for..in loop would also walk the prototype chain and copy inherited members. @@ -78,14 +78,14 @@ class TableTypeExtension extends TypeExtension { thread.pushValue(key, seenMap) thread.pushValue((target as Record)[key], seenMap) - thread.lua.lua_rawset(thread.address, tableIndex) + thread.module.lua_rawset(thread.address, tableIndex) } } } finally { // Only the outermost push owns the anchors it created for the values below it. if (cache === undefined) { for (const reference of seenMap.values()) { - thread.lua.luaL_unref(thread.address, LUA_REGISTRYINDEX, reference) + thread.module.luaL_unref(thread.address, LUA_REGISTRYINDEX, reference) } } } @@ -96,8 +96,8 @@ class TableTypeExtension extends TypeExtension { private readTableKeys(thread: Thread, index: number): string[] { const keys = [] - thread.lua.lua_pushnil(thread.address) - while (thread.lua.lua_next(thread.address, index)) { + thread.module.lua_pushnil(thread.address) + while (thread.module.lua_next(thread.address, index)) { // JS only supports string keys in objects. const key = thread.indexToString(-2) keys.push(key) @@ -111,8 +111,8 @@ class TableTypeExtension extends TypeExtension { private readTableValues(thread: Thread, index: number, seenMap: LuaGetCache, table: TableType): void { const isArray = Array.isArray(table) - thread.lua.lua_pushnil(thread.address) - while (thread.lua.lua_next(thread.address, index)) { + thread.module.lua_pushnil(thread.address) + while (thread.module.lua_next(thread.address, index)) { const key = thread.indexToString(-2) const value = thread.getValue(-1, undefined, seenMap) diff --git a/src/type-extensions/userdata.ts b/src/type-extensions/userdata.ts index 3d83d16..1e9cae3 100644 --- a/src/type-extensions/userdata.ts +++ b/src/type-extensions/userdata.ts @@ -12,20 +12,20 @@ class UserdataTypeExtension extends TypeExtension { this.gcPointer = this.createGcFunction() - if (state.lua.luaL_newmetatable(state.address, this.name)) { - const metatableIndex = state.lua.lua_gettop(state.address) + if (state.module.luaL_newmetatable(state.address, this.name)) { + const metatableIndex = state.module.lua_gettop(state.address) // Mark it as uneditable - state.lua.lua_pushstring(state.address, 'protected metatable') - state.lua.lua_setfield(state.address, metatableIndex, '__metatable') + state.module.lua_pushstring(state.address, 'protected metatable') + state.module.lua_setfield(state.address, metatableIndex, '__metatable') // Add the gc function - state.lua.lua_pushcclosure(state.address, this.gcPointer, 0) - state.lua.lua_setfield(state.address, metatableIndex, '__gc') + state.module.lua_pushcclosure(state.address, this.gcPointer, 0) + state.module.lua_setfield(state.address, metatableIndex, '__gc') } // Pop the metatable from the stack. - state.lua.lua_pop(state.address, 1) + state.module.lua_pop(state.address, 1) } public isType(_thread: Thread, _index: number, type: LuaType, name?: string): boolean { @@ -33,9 +33,9 @@ class UserdataTypeExtension extends TypeExtension { } public getValue(thread: Thread, index: number): any { - const refUserdata = thread.lua.lua_touserdata(thread.address, index) - const referencePointer = thread.lua._emscripten.getValue(refUserdata, '*') - return thread.lua.getRef(referencePointer) + const refUserdata = thread.module.lua_touserdata(thread.address, index) + const referencePointer = thread.module.emscripten.getValue(refUserdata, '*') + return thread.module.getRef(referencePointer) } public pushValue(thread: Thread, decoratedValue: Decoration): boolean { @@ -47,7 +47,7 @@ class UserdataTypeExtension extends TypeExtension { } public close(): void { - this.state.lua._emscripten.removeFunction(this.gcPointer) + this.state.module.emscripten.removeFunction(this.gcPointer) } } diff --git a/src/types.ts b/src/types.ts index abccc7b..9278754 100755 --- a/src/types.ts +++ b/src/types.ts @@ -49,7 +49,7 @@ export function resolveLibraryMask(libs: LuaLibName[] | boolean | undefined): nu for (const lib of libs) { const bit = LUA_LIB_BITS[lib] if (bit === undefined) { - throw new Error(`unknown Lua library: ${String(lib)}`) + throw new Error(`unknown Lua library '${String(lib)}', expected one of: ${Object.keys(LUA_LIB_BITS).join(', ')}`) } mask |= bit } @@ -61,11 +61,11 @@ export type LuaLoadMode = 't' | 'bt' export interface LuaMemoryOptions { /** - * Installs a custom allocator so memory can be measured and capped. Without it `state.memory` - * is undefined. + * Installs a custom allocator so memory can be measured through `state.memory`, which is + * undefined on a state that has neither this nor `max`. */ trace?: boolean | undefined - /** Maximum bytes the state may allocate. Requires `trace`. */ + /** Maximum bytes the state may allocate. Implies `trace`, since the allocator enforces it. */ max?: number | undefined } @@ -96,7 +96,7 @@ export interface CreateStateOptions { inject?: boolean | undefined memory?: LuaMemoryOptions | undefined limits?: LuaLimitOptions | undefined - /** Where diagnostics go. Defaults to `console.warn`. */ + /** Where diagnostics go. Defaults to the runtime's handler, and then to `console.warn`. */ onWarn?: LuaWarnHandler | undefined } diff --git a/test/api.test.js b/test/api.test.js index 6d7ba51..e557538 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -108,18 +108,18 @@ describe('Custom type extensions', () => { super(state, 'js_point') this.gcPointer = this.createGcFunction() - if (state.lua.luaL_newmetatable(state.address, this.name)) { - const metatableIndex = state.lua.lua_gettop(state.address) - state.lua.lua_pushcclosure(state.address, this.gcPointer, 0) - state.lua.lua_setfield(state.address, metatableIndex, '__gc') + if (state.module.luaL_newmetatable(state.address, this.name)) { + const metatableIndex = state.module.lua_gettop(state.address) + state.module.lua_pushcclosure(state.address, this.gcPointer, 0) + state.module.lua_setfield(state.address, metatableIndex, '__gc') state.pushValue((point) => `Point(${point.x})`) - state.lua.lua_setfield(state.address, metatableIndex, '__tostring') + state.module.lua_setfield(state.address, metatableIndex, '__tostring') } - state.lua.lua_pop(state.address, 1) + state.module.lua_pop(state.address, 1) } close() { - this.state.lua._emscripten.removeFunction(this.gcPointer) + this.state.module.emscripten.removeFunction(this.gcPointer) } pushValue(thread, decoration) { @@ -204,7 +204,7 @@ describe('Thread lifecycle', () => { it('indexToString should call __tostring when there is one', async () => { using state = await getState() state.set('thing', decorate({}, { metatable: { __tostring: () => 'rendered' } })) - state.lua.lua_getglobal(state.address, 'thing') + state.module.lua_getglobal(state.address, 'thing') expect(state.indexToString(-1)).to.be.equal('rendered') diff --git a/test/errors.test.js b/test/errors.test.js index f82b69c..9a7b19a 100644 --- a/test/errors.test.js +++ b/test/errors.test.js @@ -119,7 +119,7 @@ describe('Unrepresentable values', () => { it('getValue throws instead of returning an opaque handle', async () => { using state = await getState() const thread = state.newThread() - thread.lua.lua_newuserdatauv(thread.address, 4, 0) + thread.module.lua_newuserdatauv(thread.address, 4, 0) expect(() => thread.getValue(-1)).to.throw('has no JS representation') }) @@ -127,7 +127,7 @@ describe('Unrepresentable values', () => { it('the address is still reachable through getPointer', async () => { using state = await getState() const thread = state.newThread() - thread.lua.lua_newuserdatauv(thread.address, 4, 0) + thread.module.lua_newuserdatauv(thread.address, 4, 0) expect(thread.getPointer(-1)).to.be.greaterThan(0) }) @@ -141,7 +141,7 @@ describe('Warnings', () => { state.getTable('_G', () => { // Leaves the stack unbalanced on purpose. - state.lua.lua_pushnil(state.address) + state.module.lua_pushnil(state.address) }) expect(seen).to.have.lengthOf(1) diff --git a/test/initialization.test.js b/test/initialization.test.js index 4aca780..46cf787 100644 --- a/test/initialization.test.js +++ b/test/initialization.test.js @@ -1,4 +1,4 @@ -import { LuaRuntime } from '../dist/index.js' +import { LUA_LIB_BITS, LuaRuntime } from '../dist/index.js' import { expect } from 'chai' describe('Initialization', () => { @@ -102,10 +102,11 @@ describe('Standard libraries', () => { } }) - it('an unknown library name is rejected', async () => { + it('an unknown library name is rejected, naming the valid ones', async () => { const lua = await LuaRuntime.load() - expect(() => lua.createState({ libs: ['nope'] })).to.throw('unknown Lua library: nope') + expect(() => lua.createState({ libs: ['nope'] })).to.throw("unknown Lua library 'nope'") + expect(() => lua.createState({ libs: ['nope'] })).to.throw(Object.keys(LUA_LIB_BITS).join(', ')) }) }) diff --git a/test/state.test.js b/test/state.test.js index 92decdd..8fc1c08 100644 --- a/test/state.test.js +++ b/test/state.test.js @@ -364,7 +364,7 @@ describe('State', () => { }) `) - state.lua.lua_getglobal(state.address, 'sum') + state.module.lua_getglobal(state.address, 'sum') const sum = state.getValue(-1, LuaType.Function) expect(sum(10, 30)).to.be.equal(40) @@ -622,8 +622,8 @@ describe('State', () => { using state = await getState() const obj = {} state.set('obj', obj) - const refIndex = state.lua.getLastRefIndex() - const oldRef = state.lua.getRef(refIndex) + const refIndex = state.module.getLastRefIndex() + const oldRef = state.module.getRef(refIndex) await state.doString(` local weaktable = {} @@ -635,7 +635,7 @@ describe('State', () => { `) expect(oldRef).to.be.equal(obj) - const newRef = state.lua.getRef(refIndex) + const newRef = state.module.getRef(refIndex) expect(newRef).to.be.equal(undefined) }) @@ -788,7 +788,7 @@ describe('State', () => { using state = await getState() await state.doString('value = string.char(0, 255, 128, 65)') - state.lua.lua_getglobal(state.address, 'value') + state.module.lua_getglobal(state.address, 'value') const bytes = state.getStringBytes(-1) state.pop() @@ -808,12 +808,12 @@ describe('State', () => { using state = await getState() await state.doString('bytecode = string.dump(load("return 42"))') - state.lua.lua_getglobal(state.address, 'bytecode') + state.module.lua_getglobal(state.address, 'bytecode') const bytes = state.getStringBytes(-1) state.pop() state.pushStringBytes(bytes) - state.lua.lua_setglobal(state.address, 'roundtripped') + state.module.lua_setglobal(state.address, 'roundtripped') expect(await state.doString('return load(roundtripped)()')).to.be.equal(42) }) @@ -870,14 +870,14 @@ describe('State', () => { using state = await getState() const value = 9223372036854775807n - state.lua.lua_pushinteger(state.address, value) - state.lua.lua_setglobal(state.address, 'value') + state.module.lua_pushinteger(state.address, value) + state.module.lua_setglobal(state.address, 'value') const asString = await state.doString(`return tostring(value)`) const asFormatted = await state.doString(`return ("%d"):format(value)`) - state.lua.lua_getglobal(state.address, 'value') - const roundTrip = state.lua.lua_tointegerx(state.address, -1, null) + state.module.lua_getglobal(state.address, 'value') + const roundTrip = state.module.lua_tointegerx(state.address, -1, null) state.pop() expect(asString).to.be.equal('9223372036854775807') @@ -1192,10 +1192,19 @@ describe('Decoration', () => { }) describe('Memory', () => { - it('memory.max rejects without tracing', async () => { + it('memory.max turns tracing on by itself', async () => { const lua = await getLua() + using state = lua.createState({ memory: { max: 4 * 1024 * 1024 } }) - expect(() => lua.createState({ memory: { max: 1000 } })).to.throw('requires memory.trace') + expect(state.memory).to.not.be.undefined + expect(state.memory.max).to.be.equal(4 * 1024 * 1024) + expect(state.memory.used).to.be.greaterThan(0) + }) + + it('a max too small to hold a state is reported as such', async () => { + const lua = await getLua() + + expect(() => lua.createState({ memory: { max: 1000 } })).to.throw('memory.max of 1000 bytes') }) it('memory is a live view', async () => { diff --git a/utils/reference-types.js b/utils/reference-types.js new file mode 100644 index 0000000..4fd1fc1 --- /dev/null +++ b/utils/reference-types.js @@ -0,0 +1,17 @@ +// The public declarations use `@types/emscripten`'s ambient globals (EmscriptenModule, FS, +// Emscripten.*). Those only resolve for a consumer whose own tsconfig happens to list +// "emscripten" in `types`, which nothing about depending on wasmoon makes them do -- so without +// this the published .d.ts fails to compile under, say, `"types": ["node"]`. +// +// A `/// ` in the entry declaration pulls the package in for everyone. +// It has to be added here rather than written in the source, because tsc drops the directive on +// its way through declaration emit. +import { readFile, writeFile } from 'node:fs/promises' + +const ENTRY = 'dist/index.d.ts' +const DIRECTIVE = '/// ' + +const contents = await readFile(ENTRY, 'utf8') +if (!contents.startsWith(DIRECTIVE)) { + await writeFile(ENTRY, `${DIRECTIVE}\n${contents}`) +} From 639d163bdf0d9212dcb4e09b14d9ac353bbd94e2 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Tue, 28 Jul 2026 23:41:46 -0300 Subject: [PATCH 46/52] fix some cli unparity with official lua cli --- bin/wasmoon | 501 +++++++++++++++++++++++++++++------------------ test/cli.test.js | 237 ++++++++++++++++------ 2 files changed, 479 insertions(+), 259 deletions(-) diff --git a/bin/wasmoon b/bin/wasmoon index b80cf70..e3f754b 100755 --- a/bin/wasmoon +++ b/bin/wasmoon @@ -1,13 +1,21 @@ #!/usr/bin/env node -import { LuaRuntime, LuaReturn, LuaType, LUA_MULTRET, decorate } from '../dist/index.js' +import { LuaRuntime, LuaReturn, LuaType, LUA_MULTRET, LuaError } from '../dist/index.js' import pkg from '../package.json' with { type: 'json' } import fs from 'node:fs' import readline from 'node:readline' +// Prefixes diagnostics, the way the standalone interpreter prefixes them with its own argv[0]. +const PROGNAME = 'wasmoon' + +/** An argument the interpreter could not make sense of, which is reported with the usage text. */ +class UsageError extends Error {} + function printUsage() { - console.log( + // Diagnostics go to stderr, as they do in the reference implementation, so a caller piping the + // interpreter's output never has to filter them out. + console.error( ` -usage: wasmoon [options] [script [args]] +usage: ${PROGNAME} [options] [script [args]] Available options are: -e stat execute string 'stat' -i enter interactive mode after executing 'script' @@ -20,143 +28,143 @@ Available options are: - stop handling options and execute stdin `.trim(), ) - process.exit(1) } -function parseArgs(args) { - const executeSnippets = [] - const includeModules = [] +/** + * @param argv the interpreter name followed by its arguments, mirroring the reference + * interpreter's argv so `arg` can be built with the same offsets. + */ +function parseArgs(argv) { + // -e, -l and -W all run in the order they were given, so they share one list. + const actions = [] let forceInteractive = false - let warnings = false - let ignoreEnv = false let showVersion = false - let scriptFile = null + let ignoreEnv = false + // Index into argv of the script name, or 0 for no script, as in the reference implementation. + let scriptIndex = 0 + // A '-' script means stdin, unless it came after '--', which makes it an ordinary file name. + let scriptIsStdin = false - const outArgs = [] + for (let i = 1; i < argv.length; i++) { + const arg = argv[i] - let i = 0 - for (; i < args.length; i++) { - const arg = args[i] + if (!arg.startsWith('-')) { + scriptIndex = i + break + } - if (arg === '--') { - i++ + if (arg === '-') { + scriptIndex = i + scriptIsStdin = true break } - if (arg.startsWith('-') && arg.length > 1) { - switch (arg) { - case '-v': - showVersion = true - break - case '-W': - warnings = true - break - case '-E': - ignoreEnv = true - break - case '-i': - forceInteractive = true - break - case '-e': - i++ - if (i >= args.length) { - console.error('Missing argument after -e') - printUsage() - } - executeSnippets.push(args[i]) - break - case '-l': - i++ - if (i >= args.length) { - console.error('Missing argument after -l') - printUsage() - } - includeModules.push(args[i]) - break - case '-': - scriptFile = '-' - i++ - break - default: - console.log(`unrecognized option: '${arg}'`) - printUsage() - break - } - } else { - scriptFile = arg - i++ + if (arg === '--') { + // Whatever follows is the script, even when it looks like an option. + scriptIndex = i + 1 < argv.length ? i + 1 : 0 break } - } - outArgs.push(...args.slice(i)) - - return { - executeSnippets, - includeModules, - forceInteractive, - warnings, - showVersion, - scriptFile, - scriptArgs: outArgs, - ignoreEnv, + switch (arg) { + case '-v': + showVersion = true + break + case '-W': + actions.push({ type: 'W' }) + break + case '-E': + ignoreEnv = true + break + case '-i': + forceInteractive = true + break + case '-e': + case '-l': + i++ + if (i >= argv.length) { + throw new UsageError(`Missing argument after ${arg}`) + } + actions.push({ type: arg[1], value: argv[i] }) + break + default: + throw new UsageError(`unrecognized option: '${arg}'`) + } } + + return { argv, actions, forceInteractive, showVersion, ignoreEnv, scriptIndex, scriptIsStdin } } -const { executeSnippets, includeModules, ignoreEnv, forceInteractive, warnings, showVersion, scriptFile, scriptArgs } = parseArgs( - process.argv.slice(2), -) +// os.exit unwinds by throwing rather than returning, so its status has to be picked back up here. +const isExitStatus = (err) => Boolean(err) && err.name === 'ExitStatus' && typeof err.status === 'number' + +/** + * Reports the way the reference implementation does: the message goes to stderr behind the program + * name, and the interpreter stops with a failing status. + */ +function reportError(err) { + if (err instanceof UsageError) { + console.error(err.message) + printUsage() + return + } -// When running interactively, process.stdin is used by readline. -// To allow Lua’s io.read to work (even in interactive mode), we open -// a separate file descriptor to the terminal (e.g. '/dev/tty' on Unix). -let inputFD = 0 -if (process.stdin.isTTY) { - try { - inputFD = fs.openSync('/dev/tty', 'r') - } catch { - // If opening /dev/tty fails (or on non-Unix systems), fallback to fd 0. - inputFD = 0 + process.stderr.write(`${PROGNAME}: ${err instanceof LuaError ? err.luaMessage : (err?.message ?? String(err))}\n`) + if (err instanceof LuaError && err.traceback) { + process.stderr.write(`${err.traceback}\n`) } } -// Created only for the REPL: an open interface holds process.stdin in flowing mode, which both -// steals the bytes io.read is after and keeps the process alive after a script is done. -let rl = null -// Lua's io.read and the REPL share the terminal, so they can't be listening at the same time. -const pauseReadline = () => rl && !rl.closed && rl.pause() -const resumeReadline = () => rl && !rl.closed && rl.resume() - -// Bytes read past the end of a line: a single read can return more than one line, and dropping -// the remainder would lose input. -const inputChunk = Buffer.alloc(1024) -let pendingLength = 0 -let pendingOffset = 0 -// Set when a line ended on CR, so the LF of a CRLF pair doesn't start an empty line. -let skipLineFeed = false - -// Reading ahead in raw mode would strand whatever the user typed past the end of the line in here, -// where the REPL (which reads process.stdin) can never see it. -const refillInputChunk = (isRaw) => { - try { - pendingLength = fs.readSync(inputFD, inputChunk, 0, isRaw ? 1 : inputChunk.length) - } catch (err) { - // Both mean "nothing more right now", which for a blocking read is the end of the input. - if (err.code !== 'EOF' && err.code !== 'EAGAIN') { - throw err +/** + * Reads lines from the terminal for Lua's own io.read, which cannot share process.stdin with the + * REPL: an open readline interface holds it in flowing mode and would steal the bytes io.read is + * after. Owns the interface too, so the two take turns rather than listening at once. + */ +function createTerminalInput() { + // When running interactively, process.stdin is used by readline. + // To allow Lua’s io.read to work (even in interactive mode), we open + // a separate file descriptor to the terminal (e.g. '/dev/tty' on Unix). + let inputFD = 0 + if (process.stdin.isTTY) { + try { + inputFD = fs.openSync('/dev/tty', 'r') + } catch { + // If opening /dev/tty fails (or on non-Unix systems), fallback to fd 0. + inputFD = 0 } - pendingLength = 0 } - pendingOffset = 0 - return pendingLength > 0 -} -const lua = await LuaRuntime.load({ - env: ignoreEnv ? undefined : process.env, - fs: 'node', - stdin: () => { + // Created only for the REPL: an open interface also keeps the process alive after a script is done. + let rl = null + + // Bytes read past the end of a line: a single read can return more than one line, and dropping + // the remainder would lose input. + const inputChunk = Buffer.alloc(1024) + let pendingLength = 0 + let pendingOffset = 0 + // Set when a line ended on CR, so the LF of a CRLF pair doesn't start an empty line. + let skipLineFeed = false + + // Reading ahead in raw mode would strand whatever the user typed past the end of the line in here, + // where the REPL (which reads process.stdin) can never see it. + const refillInputChunk = (isRaw) => { + try { + pendingLength = fs.readSync(inputFD, inputChunk, 0, isRaw ? 1 : inputChunk.length) + } catch (err) { + // Both mean "nothing more right now", which for a blocking read is the end of the input. + if (err.code !== 'EOF' && err.code !== 'EAGAIN') { + throw err + } + pendingLength = 0 + } + pendingOffset = 0 + return pendingLength > 0 + } + + const readLine = () => { try { - pauseReadline() + if (rl && !rl.closed) { + rl.pause() + } // Raw mode is what the REPL puts the terminal in, and it comes with no echo and no // line editing, so both have to be done by hand. Any other input already has them. @@ -193,13 +201,15 @@ const lua = await LuaRuntime.load({ } if (isRaw) { - const echo = Buffer.from(line).toString('utf8') - // A character whose remaining bytes are still on their way would flash as a - // replacement character, so leave it for the next redraw. if (terminated) { process.stdout.write('\n') - } else if (!echo.endsWith('�')) { - process.stdout.write(`\x1b[2K\x1b[0G${echo}`) + } else { + const echo = Buffer.from(line).toString('utf8') + // A character whose remaining bytes are still on their way would flash as a + // replacement character, so leave it for the next redraw. + if (!echo.endsWith('�')) { + process.stdout.write(`\x1b[2K\x1b[0G${echo}`) + } } } } @@ -214,102 +224,201 @@ const lua = await LuaRuntime.load({ console.error(err) return '' } finally { - resumeReadline() + if (rl && !rl.closed) { + rl.resume() + } } - }, -}) + } -const state = lua.createState() + const openReadline = () => { + rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + removeHistoryDuplicates: true, + prompt: '> ', + }) + return rl + } -if (showVersion) { - console.log(`wasmoon ${pkg.version} (${state.get('_VERSION')})`) - process.exit(0) + return { readLine, openReadline } } -if (warnings) { - lua.module.lua_warning(state.address, '@on', 0) -} +async function repl(lua, state, terminal) { + // Bypass result verification for interactive mode + const loadcode = (code) => { + state.setTop(0) + return lua.module.luaL_loadstring(state.address, code) === LuaReturn.Ok + } -// Decorated as a value so `#arg` and `ipairs(arg)` behave like the standalone interpreter, and -// set before -l and -e run, as it does, so those can read it. -state.set('arg', decorate(scriptArgs, { as: 'value' })) + console.log(`Welcome to Wasmoon ${pkg.version} (${state.get('_VERSION')})`) + console.log('Type Lua code and press Enter to execute. Ctrl+C to exit.\n') -for (const module of includeModules) { - let [global, mod] = module.split('=') - if (!mod) { - mod = global - } - const require = state.get('require') - state.set(global, require(mod)) -} + const rl = terminal.openReadline() -for (const snippet of executeSnippets) { - await state.doString(snippet) -} + try { + rl.prompt() -const isTTY = process.stdin.isTTY -const hasProgram = Boolean(scriptFile) || executeSnippets.length > 0 + for await (const line of rl) { + // try to load (compile) it first as an expression (return ) and second as a statement + const loaded = loadcode(`return ${line}`) || loadcode(line) + if (!loaded) { + // Failed to parse + const err = state.getValue(-1, LuaType.String) + console.log(err) + rl.prompt() + continue + } + + const result = lua.module.lua_pcallk(state.address, 0, LUA_MULTRET, 0, 0, null) + if (result === LuaReturn.Ok) { + const count = state.getTop() + if (count > 0) { + const returnValues = [] + for (let i = 1; i <= count; i++) { + returnValues.push(state.indexToString(i)) + } + console.log(...returnValues) + } + } else { + console.log(state.getValue(-1, LuaType.String)) + } -if (scriptFile === '-') { - const input = fs.readFileSync(0, 'utf-8') - await state.doString(input) -} else if (scriptFile) { - await state.doFile(scriptFile) + rl.prompt() + } + } finally { + // os.exit inside the REPL leaves the interface holding stdin open, which would keep the + // process alive long after the code it was running asked to end it. + rl.close() + process.stdin.pause() + } } -const shouldInteractive = isTTY && (forceInteractive || !hasProgram) -if (shouldInteractive) { - // Bypass result verification for interactive mode - const loadcode = (code) => { - state.setTop(0) - return lua.module.luaL_loadstring(state.address, code) === LuaReturn.Ok +/** + * Builds the global 'arg' the way 'createargtable' does, aligned on the script name so it sits at + * index 0, the arguments meant for it at positive indices and everything that came before it at + * negative ones. Filled through the raw bindings because no JS value maps to those indices. + */ +function setArgTable(state, argv, scriptIndex) { + const { module, address } = state + + module.lua_createtable(address, Math.max(argv.length - scriptIndex - 1, 0), scriptIndex + 1) + for (let i = 0; i < argv.length; i++) { + module.lua_pushstring(address, argv[i]) + module.lua_rawseti(address, -2, BigInt(i - scriptIndex)) } + module.lua_setglobal(address, 'arg') +} - const version = pkg.version - const luaversion = state.get('_VERSION') - console.log(`Welcome to Wasmoon ${version} (${luaversion})`) - console.log('Type Lua code and press Enter to execute. Ctrl+C to exit.\n') +/** + * Runs `globname = require(modname)` for a '-l' option, entirely inside Lua so the global ends up + * holding the very table 'package.loaded' has rather than a copy of it. + */ +function doLibrary(state, spec) { + const { module, address } = state + const separator = spec.indexOf('=') + const global = separator === -1 ? spec : spec.slice(0, separator) + const mod = separator === -1 ? spec : spec.slice(separator + 1) + + module.lua_getglobal(address, 'require') + module.lua_pushstring(address, mod) + state.assertOk(module.lua_pcallk(address, 1, 1, 0, 0, null)) + module.lua_setglobal(address, global) +} + +async function run({ argv, actions, forceInteractive, showVersion, ignoreEnv, scriptIndex, scriptIsStdin }) { + const terminal = createTerminalInput() - rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - terminal: true, - removeHistoryDuplicates: true, - prompt: '> ', + const lua = await LuaRuntime.load({ + env: ignoreEnv ? undefined : process.env, + fs: 'node', + stdin: terminal.readLine, }) - rl.prompt() - - for await (const line of rl) { - // try to load (compile) it first as an expression (return ) and second as a statement - const loaded = loadcode(`return ${line}`) || loadcode(line) - if (!loaded) { - // Failed to parse - const err = state.getValue(-1, LuaType.String) - console.log(err) - rl.prompt() - continue + + const state = lua.createState() + + // Unlike the other options, -v does not stop the interpreter: the reference implementation + // prints the version and carries on with whatever else was asked for. + if (showVersion) { + console.log(`${PROGNAME} ${pkg.version} (${state.get('_VERSION')})`) + } + + setArgTable(state, argv, scriptIndex) + + for (const action of actions) { + switch (action.type) { + case 'W': + lua.module.lua_warning(state.address, '@on', 0) + break + case 'l': + doLibrary(state, action.value) + break + case 'e': + await state.doString(action.value, { name: '=(command line)' }) + break } + } - const result = lua.module.lua_pcallk(state.address, 0, LUA_MULTRET, 0, 0, null) - if (result === LuaReturn.Ok) { - const count = state.getTop() - if (count > 0) { - const returnValues = [] - for (let i = 1; i <= count; i++) { - returnValues.push(state.indexToString(i)) - } - console.log(...returnValues) + const hasScript = scriptIndex > 0 + const hasSnippet = actions.some((action) => action.type === 'e') + // Only a named script is called with arguments. Stdin falling in as the program when nothing + // else was named is run bare, as the reference implementation runs it. + const scriptArgs = hasScript ? argv.slice(scriptIndex + 1) : [] + + // doString and doFile call the chunk with no arguments, and the reference implementation hands + // the script everything in 'arg', so the chunk is loaded and resumed here instead. A thread of + // its own is what those two do as well, and it is what lets the script await. + const runScript = async (load) => { + // newThread leaves the thread on the state's stack, which is what keeps it from being + // collected while it runs. + const stackTop = state.getTop() + const thread = state.newThread() + try { + thread.setLimits(state.getLimits()) + load(thread) + for (const scriptArg of scriptArgs) { + thread.pushValue(scriptArg) } + await thread.run(scriptArgs.length) + } finally { + thread.close() + state.setTop(stackTop) + } + } + + // Reading stdin to EOF and loading it as one chunk is what 'luaL_loadfile(L, NULL)' does. + const runStdin = () => runScript((thread) => thread.loadString(fs.readFileSync(0, 'utf-8'), { name: '=stdin' })) + + if (hasScript) { + await (scriptIsStdin ? runStdin() : runScript((thread) => thread.loadFile(argv[scriptIndex]))) + } + + const isTTY = process.stdin.isTTY + + if (forceInteractive) { + if (isTTY) { + await repl(lua, state, terminal) + } + } else if (!hasScript && !hasSnippet && !showVersion) { + // Nothing to run was named, so stdin is the program. On a terminal that means the REPL, + // and anywhere else it is a script read to EOF. + if (isTTY) { + await repl(lua, state, terminal) } else { - console.log(state.getValue(-1, LuaType.String)) + await runStdin() } + } +} - rl.prompt() +try { + // The interpreter's own argv, the first entry being its name, so 'arg' can be built with the + // same offsets the reference implementation uses. + await run(parseArgs([process.argv[1], ...process.argv.slice(2)])) +} catch (err) { + if (isExitStatus(err)) { + process.exitCode = err.status + } else { + reportError(err) + process.exitCode = 1 } -} else if (!hasProgram) { - // If we're not interactive, and we did NOT run a file or any -e snippet, - // read from stdin until EOF. (Non-TTY or no -i, no file). - // With -e, stdin belongs to the script, as in the standalone interpreter. - const input = fs.readFileSync(0, 'utf-8') - await state.doString(input) } diff --git a/test/cli.test.js b/test/cli.test.js index 6b491f6..736ca71 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -29,6 +29,16 @@ const runCli = (args, { input, ...options } = {}) => { }) } +// For the runs that are expected to succeed, which is most of them: the only interesting part is +// what reached stdout. +const runOk = async (args, options) => { + const { code, stdout, stderr } = await runCli(args, options) + + expect(stderr, 'stderr').to.be.empty + expect(code, 'exit code').to.be.equal(0) + return stdout +} + describe('CLI', function () { this.timeout(30_000) @@ -42,95 +52,126 @@ describe('CLI', function () { rmSync(tempDir, { recursive: true, force: true }) }) + const writeScript = (name, body) => { + const path = join(tempDir, name) + writeFileSync(path, body) + return path + } + describe('scripts and snippets', () => { // Covers the single snippet case too, which is why there is no separate test for it. it('should run several -e snippets in order', async () => { - const { code, stdout, stderr } = await runCli(['-e', 'print("first")', '-e', 'print("second")']) - - expect(stderr).to.be.empty - expect(code).to.be.equal(0) - expect(stdout).to.be.equal('first\nsecond\n') + expect(await runOk(['-e', 'print("first")', '-e', 'print("second")'])).to.be.equal('first\nsecond\n') }) it('should run a script file', async () => { - const script = join(tempDir, 'hello.lua') - writeFileSync(script, 'print("from file")') + const script = writeScript('hello.lua', 'print("from file")') - const { code, stdout } = await runCli([script]) - - expect(code).to.be.equal(0) - expect(stdout).to.be.equal('from file\n') + expect(await runOk([script])).to.be.equal('from file\n') }) it('should run a script piped to stdin', async () => { - const { code, stdout } = await runCli([], { input: 'print("from bare stdin")\n' }) - - expect(code).to.be.equal(0) - expect(stdout).to.be.equal('from bare stdin\n') + expect(await runOk([], { input: 'print("from bare stdin")\n' })).to.be.equal('from bare stdin\n') }) it('should run a script piped to stdin when given -', async () => { - const { code, stdout } = await runCli(['-'], { input: 'print("from dash")\n' }) - - expect(code).to.be.equal(0) - expect(stdout).to.be.equal('from dash\n') + expect(await runOk(['-'], { input: 'print("from dash")\n' })).to.be.equal('from dash\n') }) }) describe('arg table', () => { it('should expose arg as a lua table to a script', async () => { - const script = join(tempDir, 'args.lua') - writeFileSync(script, 'print(type(arg), #arg, arg[1], arg[2], table.concat(arg, "+"))') + const script = writeScript('args.lua', 'print(type(arg), #arg, arg[1], arg[2], table.concat(arg, "+"))') - const { code, stdout, stderr } = await runCli([script, 'first', 'second']) + const stdout = await runOk([script, 'first', 'second']) - expect(stderr).to.be.empty - expect(code).to.be.equal(0) expect(stdout.trim()).to.be.equal('table\t2\tfirst\tsecond\tfirst+second') }) - // arg used to be built after the -e loop ran, so a snippet saw a nil global. - it('should expose arg to a -e snippet', async () => { - const { code, stdout, stderr } = await runCli(['-e', 'print(type(arg), #arg)']) + // The table is aligned on the script name, so it sits at index 0 with the interpreter and + // the options that came before it at negative indices. + it('should align arg on the script name', async () => { + const script = writeScript('align.lua', 'print(arg[0], arg[-1], arg[-2])') - expect(stderr).to.be.empty - expect(code).to.be.equal(0) - expect(stdout.trim()).to.be.equal('table\t0') + const stdout = await runOk(['-e', '', script]) + + expect(stdout.trim().split('\t')).to.deep.equal([script, '', '-e']) }) - it('should pass arguments after -- through to arg', async () => { - const { code, stdout, stderr } = await runCli(['-e', 'print(arg[1])', '--', '--notaflag']) + // Without a script the alignment falls back to the interpreter, which puts the options + // themselves at positive indices. + it('should align arg on the interpreter when there is no script', async () => { + const stdout = await runOk(['-e', 'print(type(arg), #arg, arg[1], arg[2])']) - expect(stderr).to.be.empty - expect(code).to.be.equal(0) - expect(stdout.trim()).to.be.equal('--notaflag') + expect(stdout.trim()).to.be.equal('table\t2\t-e\tprint(type(arg), #arg, arg[1], arg[2])') + }) + + it('should pass the script arguments to the script as varargs', async () => { + const script = writeScript('varargs.lua', 'print(...)') + + expect((await runOk([script, 'first', 'second'])).trim()).to.be.equal('first\tsecond') + }) + + it('should treat whatever follows -- as the script, even when it looks like an option', async () => { + const script = writeScript('-notaflag.lua', 'print("ran", ...)') + + expect((await runOk(['--', script, 'tail'])).trim()).to.be.equal('ran\ttail') + }) + + it('should run nothing but the options when -- ends the arguments', async () => { + expect(await runOk(['-e', 'print("only e")', '--'])).to.be.equal('only e\n') }) }) describe('options', () => { it('-v should report both versions', async () => { - const { code, stdout } = await runCli(['-v']) + const stdout = await runOk(['-v']) - expect(code).to.be.equal(0) expect(stdout.trim()).to.match(/^wasmoon \S+ \(Lua [\d.]+\)$/) expect(stdout).to.include(`wasmoon ${pkg.version} `) }) + // -v reports and carries on, rather than being a request to do nothing else. + it('-v should still run whatever else was asked for', async () => { + const stdout = await runOk(['-v', '-e', 'print("ran")']) + + expect(stdout.trim().split('\n')).to.have.lengthOf(2) + expect(stdout).to.match(/^wasmoon .*\nran\n$/) + }) + + // It still counts as something to do, so stdin is not read as a program on top of it. + it('-v alone should not run stdin', async () => { + expect(await runOk(['-v'], { input: 'print("should not run")\n' })).to.not.include('should not run') + }) + + it('should run -e and -l in the order they were given', async () => { + writeScript('noisy.lua', 'print("module") return {}') + + const [eFirst, lFirst] = await Promise.all([ + runOk(['-e', 'print("snippet")', '-l', 'noisy'], { cwd: tempDir }), + runOk(['-l', 'noisy', '-e', 'print("snippet")'], { cwd: tempDir }), + ]) + + expect(eFirst).to.be.equal('snippet\nmodule\n') + expect(lFirst).to.be.equal('module\nsnippet\n') + }) + + // The global has to hold the very table package.loaded does, not a copy of it. it('-l should require a module into a global of the same name', async () => { - writeFileSync(join(tempDir, 'mymod.lua'), 'return { v = 5 }') + writeScript('mymod.lua', 'return { v = 5 }') - const { code, stdout } = await runCli(['-l', 'mymod', '-e', 'print(mymod.v)'], { cwd: tempDir }) + const stdout = await runOk(['-l', 'mymod', '-e', 'print(mymod.v, type(mymod), mymod == package.loaded.mymod)'], { + cwd: tempDir, + }) - expect(code).to.be.equal(0) - expect(stdout.trim()).to.be.equal('5') + expect(stdout.trim()).to.be.equal('5\ttable\ttrue') }) it('-l g=mod should require a module into a renamed global', async () => { - writeFileSync(join(tempDir, 'mymod.lua'), 'return { v = 5 }') + writeScript('mymod.lua', 'return { v = 5 }') - const { code, stdout } = await runCli(['-l', 'g=mymod', '-e', 'print(g.v, mymod)'], { cwd: tempDir }) + const stdout = await runOk(['-l', 'g=mymod', '-e', 'print(g.v, mymod)'], { cwd: tempDir }) - expect(code).to.be.equal(0) expect(stdout.trim()).to.be.equal('5\tnil') }) @@ -138,20 +179,21 @@ describe('CLI', function () { const env = { ...process.env, WASMOON_CLI_TEST: 'secret' } const [withEnv, without] = await Promise.all([ - runCli(['-e', 'print(os.getenv("WASMOON_CLI_TEST"))'], { env }), - runCli(['-E', '-e', 'print(os.getenv("WASMOON_CLI_TEST"))'], { env }), + runOk(['-e', 'print(os.getenv("WASMOON_CLI_TEST"))'], { env }), + runOk(['-E', '-e', 'print(os.getenv("WASMOON_CLI_TEST"))'], { env }), ]) - expect(withEnv.stdout.trim()).to.be.equal('secret') - expect(without.stdout.trim()).to.be.equal('nil') + expect(withEnv.trim()).to.be.equal('secret') + expect(without.trim()).to.be.equal('nil') }) it('an unrecognized option should print usage and fail', async () => { - const { code, stdout } = await runCli(['-Z']) + const { code, stdout, stderr } = await runCli(['-Z']) expect(code).to.be.equal(1) - expect(stdout).to.include(`unrecognized option: '-Z'`) - expect(stdout).to.include('usage: wasmoon') + expect(stdout).to.be.empty + expect(stderr).to.include(`unrecognized option: '-Z'`) + expect(stderr).to.include('usage: wasmoon') }) it('a missing argument after -e should fail', async () => { @@ -162,31 +204,100 @@ describe('CLI', function () { }) }) + describe('errors', () => { + it('should report a runtime error with a traceback and fail', async () => { + const { code, stdout, stderr } = await runCli(['-e', 'error("boom")']) + + expect(code).to.be.equal(1) + expect(stdout).to.be.empty + expect(stderr).to.include('wasmoon: (command line):1: boom') + expect(stderr).to.include('stack traceback:') + // A Lua error is a diagnostic, not a crash of the interpreter itself. + expect(stderr).to.not.include('LuaError') + }) + + it('should report a script that cannot be opened without a traceback', async () => { + const missing = join(tempDir, 'missing.lua') + + const { code, stdout, stderr } = await runCli([missing]) + + expect(code).to.be.equal(1) + expect(stdout).to.be.empty + expect(stderr).to.include(`wasmoon: cannot open ${missing}`) + expect(stderr).to.not.include('stack traceback:') + }) + + it('should stop before the script when a -e snippet fails', async () => { + const script = writeScript('unreached.lua', 'print("unreached")') + + const { code, stdout } = await runCli(['-e', 'error("boom")', script]) + + expect(code).to.be.equal(1) + expect(stdout).to.be.empty + }) + + it('should report a failing -l without running the rest', async () => { + const { code, stdout, stderr } = await runCli(['-l', 'nosuchmodule', '-e', 'print("unreached")']) + + expect(code).to.be.equal(1) + expect(stdout).to.be.empty + expect(stderr).to.include("wasmoon: module 'nosuchmodule' not found") + }) + }) + + describe('os.exit', () => { + it('should exit with the status the script asked for', async () => { + const { code, stdout, stderr } = await runCli(['-e', 'print("bye") os.exit(3)']) + + expect(stderr).to.be.empty + expect(code).to.be.equal(3) + expect(stdout).to.be.equal('bye\n') + }) + + it('should treat a boolean status the way lua does', async () => { + const [ok, notOk] = await Promise.all([runCli(['-e', 'os.exit(true)']), runCli(['-e', 'os.exit(false)'])]) + + expect(ok.code).to.be.equal(0) + expect(notOk.code).to.be.equal(1) + expect(ok.stderr).to.be.empty + expect(notOk.stderr).to.be.empty + }) + + it('should exit successfully with no status', async () => { + const { code, stderr } = await runCli(['-e', 'os.exit()']) + + expect(stderr).to.be.empty + expect(code).to.be.equal(0) + }) + }) + describe('stdin', () => { it('should read piped input from within a script', async () => { - const { code, stdout, stderr } = await runCli(['-e', 'print(io.read("l")) print(io.read("l")) print(io.read("l") == nil)'], { + const stdout = await runOk(['-e', 'print(io.read("l")) print(io.read("l")) print(io.read("l") == nil)'], { input: 'olá mundo\nsegunda linha\n', }) - expect(stderr).to.be.empty - expect(code).to.be.equal(0) expect(stdout).to.be.equal('olá mundo\nsegunda linha\ntrue\n') }) it('should not add a line break to input that does not end with one', async () => { - const { code, stdout, stderr } = await runCli(['-e', 'print(#io.read("a"))'], { input: 'abc' }) + expect(await runOk(['-e', 'print(#io.read("a"))'], { input: 'abc' })).to.be.equal('3\n') + }) - expect(stderr).to.be.empty - expect(code).to.be.equal(0) - expect(stdout).to.be.equal('3\n') + // A named '-' is a script and is called like one; stdin falling in because nothing else was + // named is not, so the options in front of it are not arguments to it. + it('should only pass varargs to stdin when it was named with -', async () => { + const [named, fallback] = await Promise.all([ + runOk(['-', 'one', 'two'], { input: 'print("varargs:", ...)\n' }), + runOk(['-W'], { input: 'print("varargs:", ...)\n' }), + ]) + + expect(named.trim()).to.be.equal('varargs:\tone\ttwo') + expect(fallback.trim()).to.be.equal('varargs:') }) it('should not run piped input as a script when -e is given', async () => { - const { code, stdout, stderr } = await runCli(['-e', 'print("from -e")'], { input: 'this is not lua code\n' }) - - expect(stderr).to.be.empty - expect(code).to.be.equal(0) - expect(stdout).to.be.equal('from -e\n') + expect(await runOk(['-e', 'print("from -e")'], { input: 'this is not lua code\n' })).to.be.equal('from -e\n') }) }) }) From df082d5956b5f311c19e89b5477d2ef6c8fda4e5 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Wed, 29 Jul 2026 08:22:49 -0300 Subject: [PATCH 47/52] perf --- bench/index.js | 5 + bench/interop.js | 136 ++++++++++++++ bench/utils.js | 6 +- rolldown.config.ts | 28 +++ src/module.ts | 304 +++++++++++++++++++++++++++++--- src/state.ts | 43 +++-- src/thread.ts | 122 +++++++------ src/type-extension.ts | 10 +- src/type-extensions/error.ts | 2 +- src/type-extensions/function.ts | 14 +- src/type-extensions/null.ts | 2 +- src/type-extensions/promise.ts | 6 +- src/type-extensions/proxy.ts | 4 +- src/type-extensions/table.ts | 35 ++-- src/type-extensions/userdata.ts | 4 +- utils/build-wasm.sh | 7 +- 16 files changed, 593 insertions(+), 135 deletions(-) create mode 100644 bench/interop.js diff --git a/bench/index.js b/bench/index.js index f2555a9..630b047 100644 --- a/bench/index.js +++ b/bench/index.js @@ -1,4 +1,5 @@ import { runComparisonBench } from './comparisons.js' +import { runInteropBench } from './interop.js' import { runStepBench } from './steps.js' import { parseBenchOptions, printArtifactSizes, printBenchUsage } from './utils.js' @@ -15,6 +16,10 @@ if (options.suite === 'all' || options.suite === 'steps') { await runStepBench(options) } +if (options.suite === 'all' || options.suite === 'interop') { + await runInteropBench(options) +} + if (options.suite === 'all' || options.suite === 'comparisons') { await runComparisonBench(options) } diff --git a/bench/interop.js b/bench/interop.js new file mode 100644 index 0000000..251a431 --- /dev/null +++ b/bench/interop.js @@ -0,0 +1,136 @@ +import { LuaRuntime } from '../dist/index.js' +import assert from 'node:assert/strict' +import { isMainModule, parseBenchOptions, runBenchmarks } from './utils.js' + +// Every bench here keeps its Lua work trivial so the timings are dominated by the JS side of the +// boundary: pushValue/getValue, the type extension lookup and the ccall wrappers around the C API. +const CALL_COUNT = 20000 +const TABLE_SIZE = 2000 + +function withState(lua, options, body) { + return () => { + const state = lua.createState(options) + try { + return body(state) + } finally { + state.close() + } + } +} + +function createGlobalRoundTripBenchmark(lua) { + return withState(lua, {}, (state) => { + for (let i = 0; i < CALL_COUNT; i++) { + state.set('value', i) + if (state.get('value') !== i) { + throw new Error('global round trip returned the wrong value') + } + } + }) +} + +// Both directions switch between an inline copy and the built in codecs by length, so the sizes +// on either side of those thresholds are measured rather than one arbitrary string. +function createStringRoundTripBenchmark(lua, length) { + const value = 'abcdefghij'.repeat(Math.ceil(length / 10)).slice(0, length) + return withState(lua, {}, (state) => { + for (let i = 0; i < CALL_COUNT; i++) { + state.set('value', value) + if (state.get('value') !== value) { + throw new Error('string round trip returned the wrong value') + } + } + }) +} + +function createCallLuaFromJsBenchmark(lua) { + return withState(lua, {}, (state) => { + state.doStringSync('function add(a, b) return a + b end') + const add = state.get('add') + let sum = 0 + for (let i = 0; i < CALL_COUNT; i++) { + sum += add(i, 1) + } + assert.equal(sum, (CALL_COUNT * (CALL_COUNT - 1)) / 2 + CALL_COUNT) + }) +} + +function createCallJsFromLuaBenchmark(lua) { + return withState(lua, {}, (state) => { + state.set('add', (a, b) => a + b) + const total = state.doStringSync(` + local sum = 0 + for i = 1, ${CALL_COUNT} do sum = sum + add(i, 1) end + return sum + `) + assert.equal(total, (CALL_COUNT * (CALL_COUNT + 1)) / 2 + CALL_COUNT) + }) +} + +function createReadTableBenchmark(lua) { + return withState(lua, { objects: 'copy' }, (state) => { + state.doStringSync(` + data = {} + for i = 1, ${TABLE_SIZE} do data[i] = { id = i, name = "item" .. i, active = i % 2 == 0 } end + `) + const data = state.get('data') + assert.equal(data.length, TABLE_SIZE) + }) +} + +function createPushTableBenchmark(lua) { + const data = Array.from({ length: TABLE_SIZE }, (_, index) => ({ + id: index, + name: `item${index}`, + active: index % 2 === 0, + })) + return withState(lua, { objects: 'copy' }, (state) => { + state.set('data', data) + }) +} + +function createProxyAccessBenchmark(lua) { + const data = { counter: 0, nested: { value: 1 } } + return withState(lua, {}, (state) => { + state.set('data', data) + const total = state.doStringSync(` + local sum = 0 + for i = 1, ${CALL_COUNT} do sum = sum + data.nested.value end + return sum + `) + assert.equal(total, CALL_COUNT) + }) +} + +function createDoStringBenchmark(lua) { + return withState(lua, {}, (state) => { + for (let i = 0; i < 200; i++) { + state.doStringSync('return 1 + 1') + } + }) +} + +export async function runInteropBench(options = {}) { + const lua = await LuaRuntime.load() + + return runBenchmarks({ + title: 'Interop benchmarks', + benches: [ + { name: 'Global round trip (number)', run: createGlobalRoundTripBenchmark(lua) }, + { name: 'Global round trip (string, 8)', run: createStringRoundTripBenchmark(lua, 8) }, + { name: 'Global round trip (string, 24)', run: createStringRoundTripBenchmark(lua, 24) }, + { name: 'Global round trip (string, 200)', run: createStringRoundTripBenchmark(lua, 200) }, + { name: 'Call Lua from JS', run: createCallLuaFromJsBenchmark(lua) }, + { name: 'Call JS from Lua', run: createCallJsFromLuaBenchmark(lua) }, + { name: 'Read table into JS', run: createReadTableBenchmark(lua) }, + { name: 'Push table into Lua', run: createPushTableBenchmark(lua) }, + { name: 'Proxy field access from Lua', run: createProxyAccessBenchmark(lua) }, + { name: 'doStringSync', run: createDoStringBenchmark(lua) }, + ], + options, + }) +} + +if (isMainModule(import.meta.url)) { + await runInteropBench(parseBenchOptions()) +} diff --git a/bench/utils.js b/bench/utils.js index 4c6c757..5804245 100644 --- a/bench/utils.js +++ b/bench/utils.js @@ -64,7 +64,7 @@ export function printBenchUsage() { Options: -i, --iterations Measured iterations per benchmark (default: ${DEFAULT_OPTIONS.iterations}) -w, --warmup Warmup iterations per benchmark (default: ${DEFAULT_OPTIONS.warmup}) - -s, --suite Which suite to run: all, steps, comparisons + -s, --suite Which suite to run: all, steps, interop, comparisons -f, --filter Only run benchmarks whose name includes the given text -h, --help Show this help message`) } @@ -221,8 +221,8 @@ function parseIntegerOption(rawValue, flagName, { min }) { function parseSuiteOption(rawValue, flagName) { const value = parseStringOption(rawValue, flagName) - if (!['all', 'comparisons', 'steps'].includes(value)) { - throw new Error(`${flagName} must be one of: all, comparisons, steps`) + if (!['all', 'comparisons', 'interop', 'steps'].includes(value)) { + throw new Error(`${flagName} must be one of: all, comparisons, interop, steps`) } return value } diff --git a/rolldown.config.ts b/rolldown.config.ts index d5cf55e..f6e553a 100644 --- a/rolldown.config.ts +++ b/rolldown.config.ts @@ -58,6 +58,34 @@ export default defineConfig({ return { code: annotated, map: null } }, }, + { + // The glue is generated and already minified onto one line, so carrying its 85 KB of + // source in the published sourcemap costs a tenth of the package to map frames nobody + // reads. The mappings stay, only the inlined copy of the file goes. + name: 'drop-glue-source-content', + generateBundle(_options, bundle) { + // Rewritten through the emitted asset rather than the written file, because + // `chunk.map` is a snapshot of the Rust side and mutating it does not carry over. + let dropped = false + for (const file of Object.values(bundle)) { + if (file.type !== 'asset' || !file.fileName.endsWith('.map')) { + continue + } + const map = JSON.parse(file.source as string) + const index = map.sources.findIndex((source: string) => source?.endsWith('glue.js')) + if (index < 0 || !map.sourcesContent?.[index]) { + continue + } + map.sourcesContent[index] = null + file.source = JSON.stringify(map) + dropped = true + } + if (!dropped) { + // A silent miss would quietly put the 85 KB back into every published package. + this.error('the glue source was not found in any sourcemap, so nothing was dropped') + } + }, + }, { name: 'copy-glue-wasm', async writeBundle() { diff --git a/src/module.ts b/src/module.ts index e359191..184cc2e 100755 --- a/src/module.ts +++ b/src/module.ts @@ -37,7 +37,12 @@ export interface LuaEmscriptenModule extends EmscriptenModule { stringToNewUTF8: typeof stringToNewUTF8 lengthBytesUTF8: typeof lengthBytesUTF8 stringToUTF8: typeof stringToUTF8 - UTF8ToString: typeof UTF8ToString + UTF8ToString: (ptr: number, maxBytesToRead?: number, ignoreNul?: boolean) => string + // The scratch space `ccall` uses for its own C string arguments. Unwound by stackRestore + // rather than freed, so it stays correct when a Lua error longjmps out of the call. + stringToUTF8OnStack: (str: string) => number + stackSave: () => number + stackRestore: (pointer: number) => void ENV: EnvironmentVariables _realloc: (pointer: number, size: number) => number } @@ -72,9 +77,9 @@ export interface LuaModuleOptions { onWarn?: LuaWarnHandler | undefined } -// One-shot conversions, so a single stateless pair is shared by every module. Streaming output -// keeps its own decoder in createOutputWriter. -const textDecoder = new TextDecoder() +// One-shot conversions, so a single stateless encoder is shared by every module. Decoding goes +// through Emscripten's UTF8ToString, and streaming output keeps its own decoder in +// createOutputWriter. const textEncoder = new TextEncoder() // Above this a dedicated allocation is used, so one huge string cannot permanently retain the @@ -83,6 +88,51 @@ const REUSABLE_STRING_BUFFER_LIMIT = 64 * 1024 // Worst case UTF-8 expansion for a JS (UTF-16) string. const MAX_UTF8_BYTES_PER_CHAR = 3 +// C string arguments are nearly always a fixed name -- a metatable name, '__name', a global or a +// table key -- so their encoded form is kept instead of being rebuilt on every call. Bounded in +// both directions because a caller can also index with arbitrary strings; past either bound the +// wasm stack is used the way ccall does it. +const C_STRING_CACHE_LIMIT = 512 +const C_STRING_CACHE_MAX_LENGTH = 128 + +// Above this, TextEncoder beats copying byte by byte in JS (measured; TEXTDECODER=1 covers the +// other direction upstream). +const INLINE_ENCODE_LIMIT = 40 + +// The wasm value types Emscripten's function signature letters map to. `p` is a pointer, which is +// an i32 in a build without MEMORY64. +const WASM_SIGNATURE_TYPES: Record = { i: 0x7f, p: 0x7f, j: 0x7e, f: 0x7d, d: 0x7c, e: 0x6f } + +function wasmType(signature: string, letter: string): number { + const type = WASM_SIGNATURE_TYPES[letter] + if (type === undefined) { + throw new Error(`unsupported wasm signature '${signature}': no type for '${letter}'`) + } + return type +} + +/** + * A wasm module exporting a single imported function unchanged, which is how a JS callback is + * given the wasm identity the indirect function table requires. See {@link LuaModule.addFunction}. + */ +function buildTrampolineModule(signature: string): WebAssembly.Module { + const parameters = [...signature.slice(1)].map((letter) => wasmType(signature, letter)) + const results = signature[0] === 'v' ? [] : [wasmType(signature, signature[0])] + // Every count below is written as one byte, which LEB128 agrees with under 128. + if (parameters.length > 0x7f) { + throw new Error(`unsupported wasm signature '${signature}': ${parameters.length} parameters`) + } + + // func type: params -> results + const functionType = [0x60, parameters.length, ...parameters, results.length, ...results] + const typeSection = [0x01, functionType.length + 1, 0x01, ...functionType] + // import "e"."f" as function 0, then export it as "f" + const importSection = [0x02, 0x07, 0x01, 0x01, 0x65, 0x01, 0x66, 0x00, 0x00] + const exportSection = [0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x00] + + return new WebAssembly.Module(Uint8Array.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0, ...typeSection, ...importSection, ...exportSection])) +} + interface ReferenceMetadata { index: number refCount: number @@ -380,6 +430,8 @@ export default class LuaModule { public luaopen_package: (L: LuaAddress) => number public luaL_openlibs: (L: LuaAddress) => void + private readonly cStringCache = new Map() + private readonly trampolineModules = new Map() private referenceTracker = new WeakMap() private referenceMap = new Map() private availableReferences: number[] = [] @@ -391,9 +443,11 @@ export default class LuaModule { private readonly rawLuaLToLString: (L: LuaAddress, idx: number, len: number) => number private readonly rawLuaPushString: (L: LuaAddress, s: number) => number - // C writes it immediately before returning and we read it straight after with no interleaving - // await, so a single shared slot stays reentrancy safe. + // C writes these immediately before returning and we read them straight after with no + // interleaving await, so a single shared slot each stays reentrancy safe. private readonly sizeScratch: number + /** The `nresults` out parameter for {@link lua_resume}, read by `Thread.resume`. */ + public readonly resultCountScratch: number private stringBuffer = 0 public constructor(module: LuaEmscriptenModule, onWarn?: LuaWarnHandler) { @@ -554,8 +608,9 @@ export default class LuaModule { this.lua_pushlstring = this.cwrap('lua_pushlstring', 'number', ['number', 'number', 'number']) this.sizeScratch = module._malloc(PointerSize) - if (!this.sizeScratch) { - throw new Error('failed to allocate the scratch buffer for string lengths') + this.resultCountScratch = module._malloc(PointerSize) + if (!this.sizeScratch || !this.resultCountScratch) { + throw new Error('failed to allocate the scratch buffers for C out parameters') } } @@ -578,7 +633,7 @@ export default class LuaModule { return undefined } // Copied, because the caller may outlive the next heap growth. - return this.heap.slice(pointer, pointer + this.readSize(this.sizeScratch)) + return this.heap.slice(pointer, pointer + this.readPointer(this.sizeScratch)) } /** A number is a pointer to a NUL-terminated C string, and `null` pushes nil, as in C. */ @@ -593,9 +648,7 @@ export default class LuaModule { const capacity = s.length * MAX_UTF8_BYTES_PER_CHAR const pointer = this.acquireStringBuffer(capacity) try { - // encodeInto avoids materialising an intermediate Uint8Array for the whole string. - const { written } = textEncoder.encodeInto(s, this.heap.subarray(pointer, pointer + capacity)) - this.lua_pushlstring(L, pointer, written) + this.lua_pushlstring(L, pointer, this.writeString(s, pointer, capacity)) } finally { this.releaseStringBuffer(pointer) } @@ -616,13 +669,104 @@ export default class LuaModule { if (!length) { return '' } - return textDecoder.decode(this.heap.subarray(pointer, pointer + length)) + // The third argument keeps it from stopping at a NUL, since a Lua string can contain them + // and the length is already known. Built with TEXTDECODER=1 so this skips TextDecoder for + // the short names and keys that most of these are -- see utils/build-wasm.sh. + return this.emscripten.UTF8ToString(pointer, length, true) + } + + /** + * Calls `use` with a NUL terminated copy of `value` in wasm memory, valid only for that call. + * Reuses the shared string buffer, so `use` must not push another string of its own. + */ + public withCString(value: string, use: (pointer: number, length: number) => T): T { + // The NUL is not part of the length, but a caller that reads the pointer as a C string + // (a chunk name, say) needs it there. + const capacity = value.length * MAX_UTF8_BYTES_PER_CHAR + 1 + const pointer = this.acquireStringBuffer(capacity) + try { + const written = this.writeString(value, pointer, capacity - 1) + this.heap[pointer + written] = 0 + return use(pointer, written) + } finally { + this.releaseStringBuffer(pointer) + } + } + + /** + * Encodes `value` at `pointer` and returns the number of bytes written. The counterpart to + * {@link readString}: short ASCII takes a straight copy, because the subarray view TextEncoder + * needs costs more than the encoding does at that size. + */ + private writeString(value: string, pointer: number, capacity: number): number { + const heap = this.heap + const length = value.length + if (length <= INLINE_ENCODE_LIMIT) { + let index = 0 + while (index < length) { + const code = value.charCodeAt(index) + if (code > 0x7f) { + break + } + heap[pointer + index] = code + index++ + } + if (index === length) { + return length + } + // Anything non-ASCII is re-encoded from the start, overwriting what was copied above. + } + + return textEncoder.encodeInto(value, heap.subarray(pointer, pointer + capacity)).written + } + + /** + * The 32 bit word at `pointer`, without going through Emscripten's typed getValue. Also reads + * the out parameters the C API writes sizes and counts into, which are the same width. + */ + public readPointer(pointer: number): number { + return this.emscripten.HEAPU32[pointer >>> 2] + } + + /** The counterpart to {@link readPointer}. */ + public writePointer(pointer: number, value: number): void { + this.emscripten.HEAPU32[pointer >>> 2] = value + } + + /** + * Puts a JS callback in the indirect function table and returns the pointer Lua calls it + * through. Release it with {@link removeFunction}. + * + * A plain JS function cannot go into the table, so it has to be wrapped by a wasm module that + * imports and re-exports it. Emscripten's addFunction builds that module per callback, after + * first provoking a TypeError to discover it is needed. The module depends only on the + * signature, so compiling it once per signature is most of the cost: a state installs five + * callbacks, which was over half of what creating one cost. + * + * Each call takes a slot of its own. Unlike Emscripten's, these are not deduplicated by + * callback identity, so registering the same callback twice needs releasing twice. + */ + public addFunction(callback: (...args: any[]) => any, signature: string): number { + let trampoline = this.trampolineModules.get(signature) + if (trampoline === undefined) { + trampoline = buildTrampolineModule(signature) + this.trampolineModules.set(signature, trampoline) + } + + const wrapped = new WebAssembly.Instance(trampoline, { e: { f: callback } }).exports.f as (...args: any[]) => any + // Already a wasm function, so this now takes Emscripten's slot bookkeeping and nothing else. + return this.emscripten.addFunction(wrapped, signature) + } + + /** Frees a table slot taken by {@link addFunction}. */ + public removeFunction(pointer: number): void { + this.emscripten.removeFunction(pointer) } private toLString(raw: (L: LuaAddress, idx: number, len: number) => number, L: LuaAddress, idx: number, len: number | null): string { const lengthPointer = len ?? this.sizeScratch const pointer = raw(L, idx, lengthPointer) - return pointer ? this.readString(pointer, this.readSize(lengthPointer)) : '' + return pointer ? this.readString(pointer, this.readPointer(lengthPointer)) : '' } public lua_remove(luaState: LuaAddress, index: number): void { @@ -708,10 +852,6 @@ export default class LuaModule { return this.emscripten.HEAPU8 } - private readSize(pointer: number): number { - return this.emscripten.HEAPU32[pointer >>> 2] - } - private acquireStringBuffer(size: number): number { if (size > REUSABLE_STRING_BUFFER_LIMIT) { const pointer = this.emscripten._malloc(size) @@ -742,11 +882,24 @@ export default class LuaModule { returnType: Emscripten.JSType | null, argTypes: Array, ): (...args: any[]) => any { - // optimization for common case - const hasStringOrNumber = argTypes.some((argType) => argType === 'string|number') - if (!hasStringOrNumber) { - return (...args: any[]) => - this.emscripten.ccall(name, returnType, argTypes as Emscripten.JSType[], args as Emscripten.TypeCompatibleWithC[]) + const raw = (this.emscripten as unknown as Record any>)[`_${name}`] + if (typeof raw !== 'function') { + throw new Error(`the wasm module does not export '${name}'`) + } + + // Emscripten's own cwrap specializes exactly this case and nothing else, so it is inlined + // here rather than exported: a signature that needs nothing marshalled in either direction + // is just the wasm export. + if (argTypes.every((argType) => argType === 'number') && returnType !== 'string') { + return raw + } + + // Everything else, C string arguments included, goes through one hand rolled wrapper, since + // upstream would fall back to ccall -- which rebuilds its converter table, argument array + // and return handler on every call. That is most of the cost of the small C API functions, + // and these are hot: metatable names, globals, table fields. + if (!argTypes.includes('string|number')) { + return this.wrapWithStringArguments(raw, returnType, argTypes as Emscripten.JSType[]) } return (...args: any[]) => { @@ -779,6 +932,111 @@ export default class LuaModule { } } } + + /** + * Rest arguments and a spread call would land on every C API call that takes a name, so the + * arguments are named and both the argument positions and the arity are unrolled. Which slots + * hold a C string is fixed when the binding is wrapped, so it is not worked out per call. + */ + private wrapWithStringArguments( + raw: (...args: any[]) => any, + returnType: Emscripten.JSType | null, + argTypes: Emscripten.JSType[], + ): (...args: any[]) => any { + const emscripten = this.emscripten + const arity = argTypes.length + if (arity < 2 || arity > 5) { + throw new Error(`wrapWithStringArguments only unrolls arities 2 to 5, not ${arity}`) + } + if (argTypes[0] === 'string') { + // Every binding takes the lua_State there, so that slot is not unrolled below. + throw new Error('wrapWithStringArguments does not marshal the first argument') + } + + const returnsString = returnType === 'string' + const [, s1, s2, s3, s4] = argTypes.map((argType) => argType === 'string') + + return (a?: any, b?: any, c?: any, d?: any, e?: any): any => { + // Only an argument that misses the cache needs scratch space, and the fixed names that + // dominate these calls all hit it, so the stack is only touched when it is used. + let stack = 0 + try { + let pointer: number + if (s1) { + if ((pointer = this.toCString(b)) >= 0) { + b = pointer + } else { + stack ||= emscripten.stackSave() + b = emscripten.stringToUTF8OnStack(b) + } + } + if (s2) { + if ((pointer = this.toCString(c)) >= 0) { + c = pointer + } else { + stack ||= emscripten.stackSave() + c = emscripten.stringToUTF8OnStack(c) + } + } + if (s3) { + if ((pointer = this.toCString(d)) >= 0) { + d = pointer + } else { + stack ||= emscripten.stackSave() + d = emscripten.stringToUTF8OnStack(d) + } + } + if (s4) { + if ((pointer = this.toCString(e)) >= 0) { + e = pointer + } else { + stack ||= emscripten.stackSave() + e = emscripten.stringToUTF8OnStack(e) + } + } + + const result = arity === 2 ? raw(a, b) : arity === 3 ? raw(a, b, c) : arity === 4 ? raw(a, b, c, d) : raw(a, b, c, d, e) + return returnsString ? emscripten.UTF8ToString(result) : result + } finally { + if (stack) { + emscripten.stackRestore(stack) + } + } + } + } + + /** + * The C string pointer for a marshalled argument, or -1 when it has to go on the wasm stack + * instead. Cached pointers are kept for the lifetime of the module, so they stay valid across + * the reentrant calls a metamethod can make while one of them is still in flight. + */ + private toCString(value: unknown): number { + // Matches ccall: a nullish argument is a null pointer rather than the text "null". A number + // is already one, which is what the `string|number` bindings pass. + if (value === null || value === undefined) { + return 0 + } + if (typeof value === 'number') { + return value + } + + // Checked before the lookup, because hashing a long string costs more than the call saves. + const text = value as string + if (text.length > C_STRING_CACHE_MAX_LENGTH) { + return -1 + } + const cached = this.cStringCache.get(text) + if (cached !== undefined) { + return cached + } + if (this.cStringCache.size >= C_STRING_CACHE_LIMIT) { + return -1 + } + + const pointer = this.emscripten.stringToNewUTF8(text) + this.cStringCache.set(text, pointer) + return pointer + } } /** diff --git a/src/state.ts b/src/state.ts index e1fccd8..d4bab6c 100755 --- a/src/state.ts +++ b/src/state.ts @@ -64,31 +64,28 @@ function createAddress(cmodule: LuaModule, memory: LuaMemoryOptions | undefined) } const stats = { used: 0, max: memory.max } - const allocatorFunctionPointer = cmodule.emscripten.addFunction( - (_userData: number, pointer: number, oldSize: number, newSize: number): number => { - if (newSize === 0) { - if (pointer) { - stats.used -= oldSize - cmodule.emscripten._free(pointer) - } - return 0 + const allocatorFunctionPointer = cmodule.addFunction((_userData: number, pointer: number, oldSize: number, newSize: number): number => { + if (newSize === 0) { + if (pointer) { + stats.used -= oldSize + cmodule.emscripten._free(pointer) } + return 0 + } - const endMemoryDelta = pointer ? newSize - oldSize : newSize - const endMemory = stats.used + endMemoryDelta + const endMemoryDelta = pointer ? newSize - oldSize : newSize + const endMemory = stats.used + endMemoryDelta - if (newSize > oldSize && stats.max && endMemory > stats.max) { - return 0 - } + if (newSize > oldSize && stats.max && endMemory > stats.max) { + return 0 + } - const reallocated = cmodule.emscripten._realloc(pointer, newSize) - if (reallocated) { - stats.used = endMemory - } - return reallocated - }, - 'iiiii', - ) + const reallocated = cmodule.emscripten._realloc(pointer, newSize) + if (reallocated) { + stats.used = endMemory + } + return reallocated + }, 'iiiii') const address = cmodule.lua_newstate( allocatorFunctionPointer, @@ -96,7 +93,7 @@ function createAddress(cmodule: LuaModule, memory: LuaMemoryOptions | undefined) ((Date.now() >>> 0) ^ Math.floor(Math.random() * 0x100000000)) >>> 0, ) if (!address) { - cmodule.emscripten.removeFunction(allocatorFunctionPointer) + cmodule.removeFunction(allocatorFunctionPointer) // A cap the state cannot even be built under is the overwhelmingly likely cause, and it is // the one thing the caller can act on. throw new Error( @@ -263,7 +260,7 @@ export default class LuaState extends Thread { this.module.lua_close(this.address) if (this.allocatorFunctionPointer) { - this.module.emscripten.removeFunction(this.allocatorFunctionPointer) + this.module.removeFunction(this.allocatorFunctionPointer) } for (const wrapper of this.typeExtensions) { diff --git a/src/thread.ts b/src/thread.ts index dcac9ee..16e35bf 100755 --- a/src/thread.ts +++ b/src/thread.ts @@ -21,7 +21,6 @@ import { LuaTimeoutError, LuaType, type LuaWarnHandler, - PointerSize, } from './types' import { isEmscriptenUnwind, isPromise, yieldToEventLoop } from './utils' @@ -73,17 +72,13 @@ export default class Thread { /** @param options.mode defaults to `'t'`. See {@link LuaLoadOptions.mode}. */ public loadString(luaCode: string, options?: LuaLoadOptions): void { - const size = this.module.emscripten.lengthBytesUTF8(luaCode) - const pointerSize = size + 1 - const bufferPointer = this.module.emscripten._malloc(pointerSize) - try { - this.module.emscripten.stringToUTF8(luaCode, bufferPointer, pointerSize) - this.assertOk( - this.module.luaL_loadbufferx(this.address, bufferPointer, size, options?.name ?? bufferPointer, options?.mode ?? 't'), - ) - } finally { - this.module.emscripten._free(bufferPointer) - } + // Lua copies the chunk while loading, so the shared buffer can be handed straight to it + // rather than encoded into an allocation of its own. + this.assertOk( + this.module.withCString(luaCode, (pointer, size) => + this.module.luaL_loadbufferx(this.address, pointer, size, options?.name ?? pointer, options?.mode ?? 't'), + ), + ) } /** @param options.mode defaults to `'t'`. See {@link LuaLoadOptions.mode}. */ @@ -92,16 +87,15 @@ export default class Thread { } public resume(argCount = 0): LuaResumeResult { - const dataPointer = this.module.emscripten._malloc(PointerSize) - try { - this.module.emscripten.setValue(dataPointer, 0, 'i32') - const luaResult = this.module.lua_resume(this.address, null, argCount, dataPointer) - return { - result: luaResult, - resultCount: this.module.emscripten.getValue(dataPointer, 'i32'), - } - } finally { - this.module.emscripten._free(dataPointer) + // The shared slot is safe for the same reason the one behind it is: C writes the count as it + // returns and it is read straight after, with nothing interleaved. A nested resume has + // finished with the slot by the time this one's lua_resume writes to it. + const dataPointer = this.module.resultCountScratch + this.module.writePointer(dataPointer, 0) + const luaResult = this.module.lua_resume(this.address, null, argCount, dataPointer) + return { + result: luaResult, + resultCount: this.module.readPointer(dataPointer), } } @@ -118,7 +112,7 @@ export default class Thread { } public setField(index: number, name: string, value: unknown): void { - index = this.module.lua_absindex(this.address, index) + index = this.absIndex(index) this.pushValue(value) this.module.lua_setfield(this.address, index, name) } @@ -212,12 +206,17 @@ export default class Thread { } public stateToThread(L: LuaAddress): Thread { + if (L === this.address) { + return this + } return L === this.parent?.address ? this.parent : new Thread(this.module, this.typeExtensions, L, this.parent || this) } public pushValue(rawValue: unknown, cache?: LuaPushCache): void { - const decoratedValue = this.getValueDecorations(rawValue) - const target = decoratedValue.target + // Only the type extensions take a decoration, so pushing a plain primitive never has to + // allocate one. The default branch below synthesises one for the values that do need it. + const decoration = rawValue instanceof Decoration ? rawValue : undefined + const target = decoration ? decoration.target : rawValue if (target instanceof Thread) { const isMain = this.module.lua_pushthread(target.address) === 1 @@ -227,8 +226,6 @@ export default class Thread { return } - const startTop = this.getTop() - // Handle primitive types switch (typeof target) { case 'undefined': @@ -255,28 +252,35 @@ export default class Thread { case 'boolean': this.module.lua_pushboolean(this.address, target ? 1 : 0) break - default: - if (this.typeExtensions.find((wrapper) => wrapper.extension.pushValue(this, decoratedValue, cache))) { - break - } - if (target === null) { + default: { + // A type extension can be supplied by the caller, so unlike the pushes above it is + // not guaranteed to leave exactly one value behind. That is worth the two + // lua_gettop calls here, and not worth them on every primitive push. + const startTop = this.getTop() + if (this.pushWithExtension(decoration ?? new Decoration(target, {}), cache)) { + const endTop = this.getTop() + if (endTop !== startTop + 1) { + throw new Error(`pushValue expected stack size ${startTop + 1}, got ${endTop}`) + } + } else if (target === null) { this.module.lua_pushnil(this.address) - break + } else { + throw new Error(`The type '${typeof target}' is not supported by Lua`) } - throw new Error(`The type '${typeof target}' is not supported by Lua`) - } - - if (decoratedValue.options.metatable) { - this.setMetatable(-1, decoratedValue.options.metatable) + break + } } - if (this.getTop() !== startTop + 1) { - throw new Error(`pushValue expected stack size ${startTop + 1}, got ${this.getTop()}`) + // A synthesised decoration always has empty options, so this only ever acts on one the + // caller supplied. + const metatable = decoration?.options.metatable + if (metatable) { + this.setMetatable(-1, metatable) } } public setMetatable(index: number, metatable: LuaMetatable): void { - index = this.module.lua_absindex(this.address, index) + index = this.absIndex(index) if (this.module.lua_getmetatable(this.address, index)) { this.pop(1) @@ -308,7 +312,7 @@ export default class Thread { } public getValue(index: number, inputType?: LuaType, cache?: LuaGetCache): any { - index = this.module.lua_absindex(this.address, index) + index = this.absIndex(index) const type: LuaType = inputType ?? this.module.lua_type(this.address, index) @@ -338,11 +342,12 @@ export default class Thread { metatableName = this.getMetatableName(index) } - const typeExtensionWrapper = this.typeExtensions.find((wrapper) => - wrapper.extension.isType(this, index, type, metatableName), - ) - if (typeExtensionWrapper) { - return typeExtensionWrapper.extension.getValue(this, index, cache) + const extensions = this.typeExtensions + for (let i = 0; i < extensions.length; i++) { + const extension = extensions[i].extension + if (extension.isType(this, index, type, metatableName)) { + return extension.getValue(this, index, cache) + } } // Handing back an opaque Pointer hid the failure until the value was used, and @@ -363,7 +368,7 @@ export default class Thread { } if (this.hookFunctionPointer) { - this.module.emscripten.removeFunction(this.hookFunctionPointer) + this.module.removeFunction(this.hookFunctionPointer) this.hookFunctionPointer = undefined } @@ -549,7 +554,7 @@ export default class Thread { maxInstructions !== undefined ? Math.max(1, Math.min(INSTRUCTION_HOOK_COUNT, maxInstructions)) : INSTRUCTION_HOOK_COUNT if (!this.hookFunctionPointer) { - this.hookFunctionPointer = this.module.emscripten.addFunction((): void => { + this.hookFunctionPointer = this.module.addFunction((): void => { // Reads this.limits rather than closing over them, so a hook allocated for an // earlier configuration still honours the current one. const error = this.checkHookLimits() @@ -585,7 +590,22 @@ export default class Thread { return undefined } - private getValueDecorations(value: any): Decoration { - return value instanceof Decoration ? value : new Decoration(value, {}) + /** + * `lua_absindex` is the identity for an index that is already absolute, so the common case + * skips the call into wasm. + */ + private absIndex(index: number): number { + return index > 0 ? index : this.module.lua_absindex(this.address, index) + } + + /** Offers the value to each extension by descending priority. False if none claimed it. */ + private pushWithExtension(decoration: Decoration, cache: LuaPushCache | undefined): boolean { + const extensions = this.typeExtensions + for (let i = 0; i < extensions.length; i++) { + if (extensions[i].extension.pushValue(this, decoration, cache)) { + return true + } + } + return false } } diff --git a/src/type-extension.ts b/src/type-extension.ts index 580bbf6..e3d61bb 100644 --- a/src/type-extension.ts +++ b/src/type-extension.ts @@ -22,13 +22,13 @@ export default abstract class LuaTypeExtension { /** * The `__gc` handler every reference holding extension needs. The caller owns the returned - * pointer and has to release it with `removeFunction` in {@link close}. + * pointer and has to release it with the module's `removeFunction` in {@link close}. */ protected createGcFunction(): number { - return this.state.module.emscripten.addFunction((calledL: LuaAddress) => { + return this.state.module.addFunction((calledL: LuaAddress) => { // Throws a lua error which does a jump if it does not match. const userDataPointer = this.state.module.luaL_checkudata(calledL, 1, this.name) - const referencePointer = this.state.module.emscripten.getValue(userDataPointer, '*') + const referencePointer = this.state.module.readPointer(userDataPointer) this.state.module.unref(referencePointer) return LuaReturn.Ok @@ -41,7 +41,7 @@ export default abstract class LuaTypeExtension { if (!refUserdata) { throw new Error(`data does not have the expected metatable: ${this.name}`) } - const referencePointer = thread.module.emscripten.getValue(refUserdata, '*') + const referencePointer = thread.module.readPointer(refUserdata) return thread.module.getRef(referencePointer) as T } @@ -53,7 +53,7 @@ export default abstract class LuaTypeExtension { const pointer = thread.module.ref(target) // 4 = size of pointer in wasm. const userDataPointer = thread.module.lua_newuserdatauv(thread.address, PointerSize, 0) - thread.module.emscripten.setValue(userDataPointer, pointer, '*') + thread.module.writePointer(userDataPointer, pointer) if (LuaType.Nil === thread.module.luaL_getmetatable(thread.address, this.name)) { // Pop the pushed nil value and the user data. Don't need to unref because it's diff --git a/src/type-extensions/error.ts b/src/type-extensions/error.ts index 82a374f..ba39174 100644 --- a/src/type-extensions/error.ts +++ b/src/type-extensions/error.ts @@ -68,7 +68,7 @@ class ErrorTypeExtension extends TypeExtension { } public close(): void { - this.state.module.emscripten.removeFunction(this.gcPointer) + this.state.module.removeFunction(this.gcPointer) } } diff --git a/src/type-extensions/function.ts b/src/type-extensions/function.ts index f7f0919..53366e1 100644 --- a/src/type-extensions/function.ts +++ b/src/type-extensions/function.ts @@ -53,11 +53,11 @@ class FunctionTypeExtension extends TypeExtension { // Pop the metatable from the stack. state.module.lua_pop(state.address, 1) - this.functionWrapper = state.module.emscripten.addFunction((calledL: LuaAddress) => { + this.functionWrapper = state.module.addFunction((calledL: LuaAddress) => { const calledThread = state.stateToThread(calledL) const refUserdata = state.module.luaL_checkudata(calledL, state.module.lua_upvalueindex(1), this.name) - const refPointer = state.module.emscripten.getValue(refUserdata, '*') + const refPointer = state.module.readPointer(refUserdata) const { target, options: decorationOptions } = state.module.getRef(refPointer) as Decoration const argsQuantity = calledThread.getTop() @@ -105,8 +105,8 @@ class FunctionTypeExtension extends TypeExtension { } public close(): void { - this.state.module.emscripten.removeFunction(this.gcPointer) - this.state.module.emscripten.removeFunction(this.functionWrapper) + this.state.module.removeFunction(this.gcPointer) + this.state.module.removeFunction(this.functionWrapper) // Doesn't destroy the Lua thread, just function pointers. this.callbackContext.close() // Destroy the Lua thread @@ -129,7 +129,7 @@ class FunctionTypeExtension extends TypeExtension { const pointer = thread.module.ref(decoration) // 4 = size of pointer in wasm. const userDataPointer = thread.module.lua_newuserdatauv(thread.address, PointerSize, 0) - thread.module.emscripten.setValue(userDataPointer, pointer, '*') + thread.module.writePointer(userDataPointer, pointer) if (LuaType.Nil === thread.module.luaL_getmetatable(thread.address, this.name)) { // Pop the pushed userdata. @@ -153,6 +153,8 @@ class FunctionTypeExtension extends TypeExtension { thread.module.lua_pushvalue(thread.address, index) // Create a reference to the function which pops it from the stack const func = thread.module.luaL_ref(thread.address, LUA_REGISTRYINDEX) + // The reference never changes, so the bigint the i64 parameter needs is built once. + const funcReference = BigInt(func) const jsFunc = (...args: any[]): any => { // Calling a function would ideally be in the Lua context that's calling it. For example if the JS function @@ -170,7 +172,7 @@ class FunctionTypeExtension extends TypeExtension { // they can be left in inconsistent states. const callThread = this.callbackContext.newThread() try { - const internalType = callThread.module.lua_rawgeti(callThread.address, LUA_REGISTRYINDEX, BigInt(func)) + const internalType = callThread.module.lua_rawgeti(callThread.address, LUA_REGISTRYINDEX, funcReference) if (internalType !== LuaType.Function) { const callMetafieldType = callThread.module.luaL_getmetafield(callThread.address, -1, '__call') callThread.pop() diff --git a/src/type-extensions/null.ts b/src/type-extensions/null.ts index ea3453c..35c2e2e 100644 --- a/src/type-extensions/null.ts +++ b/src/type-extensions/null.ts @@ -66,7 +66,7 @@ class NullTypeExtension extends TypeExtension { } public close(): void { - this.state.module.emscripten.removeFunction(this.gcPointer) + this.state.module.removeFunction(this.gcPointer) } } diff --git a/src/type-extensions/promise.ts b/src/type-extensions/promise.ts index 19b4605..9dcffea 100644 --- a/src/type-extensions/promise.ts +++ b/src/type-extensions/promise.ts @@ -62,7 +62,7 @@ class PromiseTypeExtension extends TypeExtension> { promiseResult = { status: 'rejected', value: err } }) - const continuance = this.state.module.emscripten.addFunction((continuanceState: LuaAddress) => { + const continuance = this.state.module.addFunction((continuanceState: LuaAddress) => { // If this yield has been called from within a coroutine and so manually resumed // then there may not yet be any results. In that case yield again. if (!promiseResult) { @@ -74,7 +74,7 @@ class PromiseTypeExtension extends TypeExtension> { return state.module.lua_yieldk(functionThread.address, 0, 0, continuance) } - this.state.module.emscripten.removeFunction(continuance) + this.state.module.removeFunction(continuance) const continuanceThread = state.stateToThread(continuanceState) @@ -127,7 +127,7 @@ class PromiseTypeExtension extends TypeExtension> { } public close(): void { - this.state.module.emscripten.removeFunction(this.gcPointer) + this.state.module.removeFunction(this.gcPointer) } public pushValue(thread: Thread, decoration: Decoration): boolean { diff --git a/src/type-extensions/proxy.ts b/src/type-extensions/proxy.ts index 2346aaa..6b82e53 100644 --- a/src/type-extensions/proxy.ts +++ b/src/type-extensions/proxy.ts @@ -114,7 +114,7 @@ class ProxyTypeExtension extends TypeExtension { public getValue(thread: Thread, index: number): any { const refUserdata = thread.module.lua_touserdata(thread.address, index) - const referencePointer = thread.module.emscripten.getValue(refUserdata, '*') + const referencePointer = thread.module.readPointer(refUserdata) return thread.module.getRef(referencePointer) } @@ -153,7 +153,7 @@ class ProxyTypeExtension extends TypeExtension { } public close(): void { - this.state.module.emscripten.removeFunction(this.gcPointer) + this.state.module.removeFunction(this.gcPointer) } } diff --git a/src/type-extensions/table.ts b/src/type-extensions/table.ts index 3564020..a725c48 100644 --- a/src/type-extensions/table.ts +++ b/src/type-extensions/table.ts @@ -26,11 +26,10 @@ class TableTypeExtension extends TypeExtension { let table = seenMap.get(pointer) as TableType | undefined if (!table) { - const keys = this.readTableKeys(thread, index) - - const isSequential = keys.length > 0 && keys.every((key, keyIndex) => key === String(keyIndex + 1)) - table = isSequential ? [] : {} + table = this.isSequential(thread, index) ? [] : {} + // Registered before the values are read, so a table that contains itself resolves to + // this same object rather than to a second copy of it. seenMap.set(pointer, table) this.readTableValues(thread, index, seenMap, table) } @@ -93,19 +92,28 @@ class TableTypeExtension extends TypeExtension { return true } - private readTableKeys(thread: Thread, index: number): string[] { - const keys = [] + /** + * A table becomes a JS array only when `lua_next` walks exactly the keys "1".."n" in that + * order. Converting keys stops as soon as that is ruled out, because the keys this pass looks + * at are read again alongside the values. + */ + private isSequential(thread: Thread, index: number): boolean { + let count = 0 thread.module.lua_pushnil(thread.address) while (thread.module.lua_next(thread.address, index)) { // JS only supports string keys in objects. - const key = thread.indexToString(-2) - keys.push(key) + if (thread.indexToString(-2) !== String(count + 1)) { + // Pop the key and the value, since the walk is being abandoned part way. + thread.pop(2) + return false + } + count++ // Pop the value. thread.pop() } - return keys + return count > 0 } private readTableValues(thread: Thread, index: number, seenMap: LuaGetCache, table: TableType): void { @@ -113,13 +121,12 @@ class TableTypeExtension extends TypeExtension { thread.module.lua_pushnil(thread.address) while (thread.module.lua_next(thread.address, index)) { - const key = thread.indexToString(-2) - const value = thread.getValue(-1, undefined, seenMap) - if (isArray) { - table.push(value) + // An array takes its order from the walk, so its keys are never converted. + table.push(thread.getValue(-1, undefined, seenMap)) } else { - table[key] = value + const key = thread.indexToString(-2) + table[key] = thread.getValue(-1, undefined, seenMap) } thread.pop() diff --git a/src/type-extensions/userdata.ts b/src/type-extensions/userdata.ts index 1e9cae3..4a87082 100644 --- a/src/type-extensions/userdata.ts +++ b/src/type-extensions/userdata.ts @@ -34,7 +34,7 @@ class UserdataTypeExtension extends TypeExtension { public getValue(thread: Thread, index: number): any { const refUserdata = thread.module.lua_touserdata(thread.address, index) - const referencePointer = thread.module.emscripten.getValue(refUserdata, '*') + const referencePointer = thread.module.readPointer(refUserdata) return thread.module.getRef(referencePointer) } @@ -47,7 +47,7 @@ class UserdataTypeExtension extends TypeExtension { } public close(): void { - this.state.module.emscripten.removeFunction(this.gcPointer) + this.state.module.removeFunction(this.gcPointer) } } diff --git a/utils/build-wasm.sh b/utils/build-wasm.sh index 921f1d5..9d8db45 100755 --- a/utils/build-wasm.sh +++ b/utils/build-wasm.sh @@ -11,7 +11,9 @@ if [ "$1" == "dev" ]; then extension="-O0 -g3 -s ASSERTIONS=1 -s SAFE_HEAP=1 -s STACK_OVERFLOW_CHECK=2" else - extension="-Oz -fno-inline-functions -s BINARYEN_EXTRA_PASSES=gufa-optimizing" + # TEXTDECODER=1 keeps Emscripten's short string decode path, which -Oz would otherwise drop + # in favour of always calling TextDecoder. LuaModule.readString relies on it. + extension="-Oz -fno-inline-functions -s TEXTDECODER=1 -s BINARYEN_EXTRA_PASSES=gufa-optimizing" fi emcc \ @@ -29,6 +31,9 @@ emcc \ 'lengthBytesUTF8', \ 'stringToUTF8', \ 'stringToNewUTF8', \ + 'stringToUTF8OnStack', \ + 'stackSave', \ + 'stackRestore', \ 'UTF8ToString', \ 'HEAPU8', \ 'HEAPU32' From fe81283a2525bc9a88434f8d19d8ac7342bf9ea4 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Wed, 29 Jul 2026 09:50:04 -0300 Subject: [PATCH 48/52] fix: memory leaks --- src/module.ts | 14 ++++- src/type-extension.ts | 15 ++++- src/type-extensions/function.ts | 11 ++-- src/type-extensions/promise.ts | 105 ++++++++++++++++++++------------ test/promises.test.js | 71 ++++++++++++++++++++- 5 files changed, 168 insertions(+), 48 deletions(-) diff --git a/src/module.ts b/src/module.ts index 184cc2e..3cc2207 100755 --- a/src/module.ts +++ b/src/module.ts @@ -818,7 +818,10 @@ export default class LuaModule { } const metadata = this.referenceTracker.get(ref) if (metadata === undefined) { - this.referenceTracker.delete(ref) + // Dropping the map entry too, both to release the value and to keep the invariant the + // index allocation above relies on: every index below the high water mark is either + // live in referenceMap or waiting in availableReferences, never neither and never both. + this.referenceMap.delete(index) this.availableReferences.push(index) return } @@ -1077,6 +1080,12 @@ function createInputReader(reader?: () => string | null | undefined): (() => num // Emscripten hands output over one byte at a time, so it has to be reassembled before it can be // decoded. Doubles from here as lines get longer. const INITIAL_OUTPUT_CAPACITY = 256 +// Grown past this the buffer is dropped back to it once the line is out, rather than kept for the +// module's lifetime: a single huge `io.write` would otherwise retain its whole capacity forever, +// twice over for a runtime with both stdout and stderr. Dropping all the way back to the initial +// capacity instead would make a script that writes long lines in a loop regrow from 256 bytes on +// every one. +const MAX_RETAINED_OUTPUT_CAPACITY = 64 * 1024 function createOutputWriter(writer?: (content: string) => void): ((charCode: number | null) => void) | null { if (!writer) { @@ -1100,6 +1109,9 @@ function createOutputWriter(writer?: (content: string) => void): ((charCode: num // dangling character in the decoder to be completed by the next flush. const content = decoder.decode(buffer.subarray(0, length), { stream: !endOfLine }) length = 0 + if (buffer.length > MAX_RETAINED_OUTPUT_CAPACITY) { + buffer = new Uint8Array(MAX_RETAINED_OUTPUT_CAPACITY) + } // An empty line is worth reporting, an incomplete character is not. if (endOfLine || content.length > 0) { writer(content) diff --git a/src/type-extension.ts b/src/type-extension.ts index e3d61bb..6d5d377 100644 --- a/src/type-extension.ts +++ b/src/type-extension.ts @@ -18,6 +18,16 @@ export default abstract class LuaTypeExtension { return type === LuaType.Userdata && name === this.name } + /** + * Releases what the extension owns outside Lua: the function pointers it took from + * `addFunction`, and any JS side bookkeeping. + * + * Called only from {@link LuaState.close}, and only after `lua_close` has already run every + * `__gc` handler and freed the state along with its registry and every thread anchored there. + * So the pointers are still valid here and the `__gc` handlers still needed them a moment ago, + * but nothing may touch the state itself -- unreffing a registry slot or calling into a thread + * at this point is a use after free. + */ public abstract close(): void /** @@ -56,9 +66,10 @@ export default abstract class LuaTypeExtension { thread.module.writePointer(userDataPointer, pointer) if (LuaType.Nil === thread.module.luaL_getmetatable(thread.address, this.name)) { - // Pop the pushed nil value and the user data. Don't need to unref because it's - // already associated with the user data pointer. + // Pop the pushed nil value and the user data. The reference has to be released by hand: + // without a metatable the userdata has no __gc, so nothing else ever would. thread.pop(2) + thread.module.unref(pointer) throw new Error(`metatable not found: ${this.name}`) } diff --git a/src/type-extensions/function.ts b/src/type-extensions/function.ts index 53366e1..ebae0f5 100644 --- a/src/type-extensions/function.ts +++ b/src/type-extensions/function.ts @@ -19,7 +19,6 @@ class FunctionTypeExtension extends TypeExtension { private gcPointer: number private functionWrapper: number private callbackContext: Thread - private callbackContextIndex: number /** Milliseconds a Lua function called from JS may run before being interrupted. */ private readonly functionTimeout: number | undefined @@ -31,8 +30,9 @@ class FunctionTypeExtension extends TypeExtension { // interfering with the global context. This creates a callback context that will always exist // even if the thread that called getValue() has been destroyed. this.callbackContext = state.newThread() - // Pops it from the global stack but keeps it alive - this.callbackContextIndex = this.state.module.luaL_ref(state.address, LUA_REGISTRYINDEX) + // Pops it from the global stack but keeps it alive. The reference is never released, for the + // reason given on LuaTypeExtension.close. + this.state.module.luaL_ref(state.address, LUA_REGISTRYINDEX) if (!this.functionRegistry) { state.warn('FunctionTypeExtension: FinalizationRegistry not found. Memory leaks likely.') @@ -107,10 +107,9 @@ class FunctionTypeExtension extends TypeExtension { public close(): void { this.state.module.removeFunction(this.gcPointer) this.state.module.removeFunction(this.functionWrapper) - // Doesn't destroy the Lua thread, just function pointers. + // Doesn't destroy the Lua thread, just function pointers. The thread itself went with the + // state. this.callbackContext.close() - // Destroy the Lua thread - this.callbackContext.module.luaL_unref(this.callbackContext.address, LUA_REGISTRYINDEX, this.callbackContextIndex) } public isType(_thread: Thread, _index: number, type: LuaType): boolean { diff --git a/src/type-extensions/promise.ts b/src/type-extensions/promise.ts index 9dcffea..63e8c4a 100644 --- a/src/type-extensions/promise.ts +++ b/src/type-extensions/promise.ts @@ -8,13 +8,68 @@ import type { LuaAddress } from '../types' import { isPromise } from '../utils' import { decorate } from '../decoration' +/** The half of an in flight `:await()` the continuation needs once the promise has settled. */ +interface PendingAwait { + result: { status: 'fulfilled' | 'rejected'; value: any } | undefined +} + class PromiseTypeExtension extends TypeExtension> { private gcPointer: number + /** + * Keyed by the address of the thread parked in the await. A thread suspended in `lua_yieldk` + * cannot reach another `:await()`, so at most one is ever in flight per thread. + */ + private readonly pendingAwaits = new Map() + /** + * One continuation for every await on this state. Building one per await meant compiling and + * instantiating a wasm trampoline each time, and leaked the table slot whenever the coroutine + * was abandoned before the continuation ran -- a run cut short by a deadline, say. + */ + private readonly continuancePointer: number public constructor(state: LuaState, injectObject: boolean) { super(state, 'js_promise') this.gcPointer = this.createGcFunction() + this.continuancePointer = state.module.addFunction((continuanceState: LuaAddress): number => { + const pending = this.pendingAwaits.get(continuanceState) + if (!pending) { + // Nothing sensible is left to resume with, and returning would hand Lua a stack it + // does not expect. + throw new Error('a promise continuation ran without a pending await') + } + + // If this yield has been called from within a coroutine and so manually resumed + // then there may not yet be any results. In that case yield again. The continuation + // runs on the thread that yielded, so this is the address the await parked on. + if (!pending.result) { + // 0 because this is called between resumes so the first one should've popped the + // promise before returning the result. This is true within Lua's coroutine.resume + // too. + return state.module.lua_yieldk(continuanceState, 0, 0, this.continuancePointer) + } + + const { status, value } = pending.result + this.pendingAwaits.delete(continuanceState) + const continuanceThread = state.stateToThread(continuanceState) + + if (status === 'rejected') { + continuanceThread.pushValue(value || new Error('promise rejected with no error')) + return state.module.lua_error(continuanceState) + } + + if (value instanceof RawResult) { + return value.count + } else if (value instanceof MultiReturn) { + for (const arg of value) { + continuanceThread.pushValue(arg) + } + return value.length + } else { + continuanceThread.pushValue(value) + return 1 + } + }, 'iiii') if (state.module.luaL_newmetatable(state.address, this.name)) { const metatableIndex = state.module.lua_gettop(state.address) @@ -51,53 +106,22 @@ class PromiseTypeExtension extends TypeExtension> { throw new Error('cannot await in a thread that cannot yield, use doString instead of doStringSync') } - let promiseResult: { status: 'fulfilled' | 'rejected'; value: any } | undefined = undefined + const pending: PendingAwait = { result: undefined } + this.pendingAwaits.set(functionThread.address, pending) const awaitPromise = self .then((res) => { - promiseResult = { status: 'fulfilled', value: res } + pending.result = { status: 'fulfilled', value: res } return res }) .catch((err) => { - promiseResult = { status: 'rejected', value: err } + pending.result = { status: 'rejected', value: err } }) - const continuance = this.state.module.addFunction((continuanceState: LuaAddress) => { - // If this yield has been called from within a coroutine and so manually resumed - // then there may not yet be any results. In that case yield again. - if (!promiseResult) { - // 1 is because the initial yield pushed a promise reference so this pops - // it and re-returns it. - // 0 because this is called between resumes so the first one should've - // popped the promise before returning the result. This is true within - // Lua's coroutine.resume too. - return state.module.lua_yieldk(functionThread.address, 0, 0, continuance) - } - - this.state.module.removeFunction(continuance) - - const continuanceThread = state.stateToThread(continuanceState) - - if (promiseResult.status === 'rejected') { - continuanceThread.pushValue(promiseResult.value || new Error('promise rejected with no error')) - return this.state.module.lua_error(continuanceState) - } - - if (promiseResult.value instanceof RawResult) { - return promiseResult.value.count - } else if (promiseResult.value instanceof MultiReturn) { - for (const arg of promiseResult.value) { - continuanceThread.pushValue(arg) - } - return promiseResult.value.length - } else { - continuanceThread.pushValue(promiseResult.value) - return 1 - } - }, 'iiii') - + // 1 result, because the yield hands the promise reference back so the + // resume that follows can wait on it. functionThread.pushValue(awaitPromise) - return new RawResult(state.module.lua_yieldk(functionThread.address, 1, 0, continuance)) + return new RawResult(state.module.lua_yieldk(functionThread.address, 1, 0, this.continuancePointer)) }, { receiveThread: true }, ), @@ -128,6 +152,11 @@ class PromiseTypeExtension extends TypeExtension> { public close(): void { this.state.module.removeFunction(this.gcPointer) + this.state.module.removeFunction(this.continuancePointer) + // A coroutine abandoned mid await never reaches its continuation, so its record is still + // here holding whatever the promise settled with. Nothing tells us when Lua's own GC took + // that coroutine, so these are bounded by the state's lifetime rather than the await's. + this.pendingAwaits.clear() } public pushValue(thread: Thread, decoration: Decoration): boolean { diff --git a/test/promises.test.js b/test/promises.test.js index 17947fc..c839f88 100644 --- a/test/promises.test.js +++ b/test/promises.test.js @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { getState, tick } from './utils.js' +import { getLua, getState, tick } from './utils.js' import { mock } from 'node:test' describe('Promises', () => { @@ -296,4 +296,73 @@ describe('Promises', () => { state.doStringSync(`sleep(5):await()`) }).to.throw('cannot await in a thread that cannot yield') }) + + it('an await abandoned mid flight should not leak a function table slot', async function () { + // Building the trampolines the probe needs is what makes this slow, not the Lua work. + this.timeout(30000) + const lua = await getLua() + // The table object itself is not exported, so its length is probed instead: Emscripten + // reuses freed slots before growing the table, so a batch bigger than any plausible + // freelist exhausts that and the highest index handed out tracks the real length. + const tableHighWater = () => { + const pointers = [] + for (let i = 0; i < 200; i++) { + pointers.push(lua.module.addFunction(() => 0, 'ii')) + } + const max = Math.max(...pointers) + for (const pointer of pointers) { + lua.module.removeFunction(pointer) + } + return max + } + + const runAbandonedAwait = async () => { + using state = lua.createState({ inject: true }) + state.set('slow', () => new Promise((resolve) => setTimeout(resolve, 50))) + await expect(state.doString('slow():await()', { timeout: 5 })).eventually.to.be.rejectedWith('timeout') + } + + // Once first, so nothing the first state builds is counted as growth. + await runAbandonedAwait() + const before = tableHighWater() + for (let i = 0; i < 10; i++) { + await runAbandonedAwait() + } + + expect(tableHighWater()).to.equal(before) + }) + + it('several coroutines awaiting at once should each get their own result', async () => { + using state = await getState() + state.set('sleep', (ms) => new Promise((resolve) => setTimeout(() => resolve(ms), ms))) + + // Resumed in the opposite order to the one they settle in, so a continuation that mixed up + // which thread it belonged to would show up here. + const res = await state.doString(` + local threads, results = {}, {} + for i = 1, 5 do + threads[i] = coroutine.create(function() + return sleep((6 - i) * 5):await() + end) + end + local done = 0 + while done < 5 do + -- Hand control back to JS so the timers behind the promises can fire. + coroutine.yield() + for i = 1, 5 do + if coroutine.status(threads[i]) == 'suspended' then + local ok, value = coroutine.resume(threads[i]) + if not ok then error(value) end + if coroutine.status(threads[i]) == 'dead' then + done = done + 1 + results[i] = value + end + end + end + end + return table.concat(results, ',') + `) + + expect(res).to.equal('25,20,15,10,5') + }) }) From 21bb5e8e557e5e3691613382612838e1e42c6134 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Wed, 29 Jul 2026 11:40:01 -0300 Subject: [PATCH 49/52] better fs support --- README.md | 40 ++++ bin/wasmoon | 3 +- package-lock.json | 3 + package.json | 7 +- rolldown.config.ts | 101 +++++--- src/index.ts | 1 + src/module.ts | 283 ++++++++++++++++------ src/runtime.ts | 52 ++++- test/browser.test.js | 2 +- test/bundling.test.js | 26 ++- test/filesystem.test.js | 75 ++++-- test/host-filesystem.test.js | 341 +++++++++++++++++++++++++++ test/luatests.js | 4 +- test/mounts.test.js | 141 +++++++++++ test/nodefs.test.js | 442 ----------------------------------- test/utils.js | 38 +++ utils/build-wasm.sh | 73 ++++-- 17 files changed, 1041 insertions(+), 591 deletions(-) create mode 100644 test/host-filesystem.test.js create mode 100644 test/mounts.test.js delete mode 100644 test/nodefs.test.js diff --git a/README.md b/README.md index d5d12ba..cee9827 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,46 @@ sandbox. `lua.close()` closes every state created from it, and both types suppor await using lua = await LuaRuntime.load() ``` +## Filesystem + +Every state on a runtime shares one filesystem. By default it is in memory: the same in Node, the +browser and a worker, and it holds nothing but what you put in it. + +```js +const lua = await LuaRuntime.load() +lua.writeFile('/scripts/greet.lua', 'return "hello"') + +const state = lua.createState() +await state.doString('return require("scripts.greet")') +``` + +`writeFile`, `readFile`, `readTextFile`, `exists`, `cwd` and `chdir` cover the everyday cases; +`lua.filesystem` is Emscripten's own API for everything else. + +### Reaching the host + +Two ways, and neither is the default: + +```js +// One host directory, at a path you choose. Node only. Nothing else on the host is reachable. +const lua = await LuaRuntime.load({ mounts: { '/scripts': './lua' } }) +await lua.createState().doString('return dofile("/scripts/init.lua")') + +// Or the real filesystem, with the process working directory, absolute paths and symlinks. +// Node only, and not a sandbox: Lua can read and write whatever the process can. +const cli = await LuaRuntime.load({ fs: 'host' }) +``` + +`mounts` maps the path Lua sees to a host directory that has to exist; mount points cannot nest, and +`lua.mount(virtualPath, hostPath)` / `lua.unmount(virtualPath)` do the same after loading. With +`fs: 'host'` there is nothing to mount, because every host path already resolves — and `lua.chdir` +moves the Node process itself, since a process has only one working directory. + +> [!WARNING] +> A filesystem is not a sandbox on its own. Lua's `os.execute` runs a real command through the shell +> in Node whichever filesystem you pick, and `os.exit` sets the host process exit code. Leave `os` +> out of the state (`createState({ libs: [...] })`) if untrusted code must not do either. + ## CLI Usage 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). diff --git a/bin/wasmoon b/bin/wasmoon index e3f754b..47611a9 100755 --- a/bin/wasmoon +++ b/bin/wasmoon @@ -331,7 +331,8 @@ async function run({ argv, actions, forceInteractive, showVersion, ignoreEnv, sc const lua = await LuaRuntime.load({ env: ignoreEnv ? undefined : process.env, - fs: 'node', + // An interpreter is expected to see the files the shell that started it sees. + fs: 'host', stdin: terminal.readLine, }) diff --git a/package-lock.json b/package-lock.json index 0e14f85..68cd436 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,9 @@ "rolldown": "1.2.0", "tslib": "2.8.1", "typescript": "7.0.2" + }, + "engines": { + "node": ">=24" } }, "node_modules/@emnapi/wasi-threads": { diff --git a/package.json b/package.json index d46f859..c997139 100755 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "./package.json": "./package.json" }, "browser": { - "node:fs": false, - "node:module": false + "node:module": false, + "./dist/glue-host.js": false }, "sideEffects": false, "scripts": { @@ -34,7 +34,8 @@ }, "files": [ "bin/*", - "dist/*" + "dist/*", + "!dist/*.map" ], "engines": { "node": ">=24" diff --git a/rolldown.config.ts b/rolldown.config.ts index f6e553a..6a8ca3a 100644 --- a/rolldown.config.ts +++ b/rolldown.config.ts @@ -6,11 +6,24 @@ import { UNWIND_BRAND } from './src/utils' export default defineConfig({ input: './src/index.ts', output: { - file: 'dist/index.js', + // A directory rather than a single file, because the host filesystem glue that module.ts + // imports on demand has to stay a chunk of its own: a browser bundle then leaves it alone, + // and Node loads it only when someone asks for `fs: 'host'`. + dir: 'dist', + entryFileNames: 'index.js', + // Named rather than hashed, so the `browser` field in package.json can point at it. + chunkFileNames: 'glue-host.js', format: 'esm', sourcemap: true, + // The glue arrives from emcc already minified and rolldown would otherwise re-print it + // several kilobytes larger than it started. Consumers minify us anyway -- which the + // Bundling suite covers -- so doing it here only decides what the package and a CDN + // consumer download. The sourcemap keeps our own frames readable. + minify: { mangle: { toplevel: false } }, }, - external: ['node:module', 'node:fs'], + // Only the glue imports a node builtin now; the mounts reach node:fs through + // process.getBuiltinModule, which a bundler never has to resolve. + external: ['node:module'], plugins: [ { name: 'package-version', @@ -45,44 +58,76 @@ export default defineConfig({ }, }, { - // Both node builtins are imported behind a runtime check for Node, which a bundler - // targeting the browser does not see, so it refuses to resolve them. webpack takes the - // comment; the others go by the `browser` field in package.json. + // A node builtin is imported behind a runtime check for Node, which a bundler targeting + // the browser does not see, so it refuses to resolve it. webpack takes the comment; the + // others go by the `browser` field in package.json. + // + // In generateBundle rather than renderChunk because minification runs in between and + // strips comments: annotating earlier would leave the guard below passing while the + // published chunk had no annotation left in it. Column mappings on the one line this + // touches shift by the length of the comment, which only ever falls inside the glue -- + // whose source the next plugin drops from the sourcemap anyway. name: 'annotate-node-imports', - renderChunk(code) { - const annotated = code.replace(/import\((['"])(node:[^'"]+)\1\)/g, 'import(/* webpackIgnore: true */ $1$2$1)') - const missed = /import\((?!\s*\/\*)(['"])node:/.exec(annotated) - if (missed) { - this.error(`a node builtin import was left unannotated: ${missed[0]}`) + generateBundle(_options, bundle) { + for (const chunk of Object.values(bundle)) { + if (chunk.type !== 'chunk') { + continue + } + // Quotes of either kind, since a minifier picks its own, and the whitespace is + // not optional to match: an import the output happens to wrap over several lines + // is the same import, and skipping it would ship it unannotated. + chunk.code = chunk.code.replace(/import\(\s*(['"`])(node:[^'"`]+)\1\s*\)/g, 'import(/* webpackIgnore: true */ $1$2$1)') + const missed = /import\(\s*(?!\/\*)(['"`])node:/.exec(chunk.code) + if (missed) { + this.error(`${chunk.fileName} ships a node builtin import with no annotation: ${missed[0]}`) + } } - return { code: annotated, map: null } }, }, { - // The glue is generated and already minified onto one line, so carrying its 85 KB of - // source in the published sourcemap costs a tenth of the package to map frames nobody - // reads. The mappings stay, only the inlined copy of the file goes. - name: 'drop-glue-source-content', + // The glue is generated and already minified onto one line, so its source is not worth + // publishing: carrying it costs a tenth of the package to map frames nobody reads. What + // is left of each sourcemap depends on whether anything else is in the chunk. + name: 'trim-glue-sourcemaps', generateBundle(_options, bundle) { - // Rewritten through the emitted asset rather than the written file, because + // Rewritten through the emitted files rather than the written ones, because // `chunk.map` is a snapshot of the Rust side and mutating it does not carry over. - let dropped = false - for (const file of Object.values(bundle)) { - if (file.type !== 'asset' || !file.fileName.endsWith('.map')) { + // Counted per sourcemap: there is one for each glue now, and a single flag would + // call it a success while the other still shipped its copy. + let trimmed = 0 + for (const chunk of Object.values(bundle)) { + const map = bundle[`${chunk.fileName}.map`] + if (chunk.type !== 'chunk' || map?.type !== 'asset') { continue } - const map = JSON.parse(file.source as string) - const index = map.sources.findIndex((source: string) => source?.endsWith('glue.js')) - if (index < 0 || !map.sourcesContent?.[index]) { + const parsed = JSON.parse(map.source as string) + const index = parsed.sources.findIndex((source: string) => source?.endsWith('glue.js')) + if (index < 0) { continue } - map.sourcesContent[index] = null - file.source = JSON.stringify(map) - dropped = true + + if (parsed.sources.length === 1) { + // Nothing but the glue in this chunk, so the mappings lead only to a file + // that is not published and whose content is dropped below anyway. The whole + // sourcemap goes, and with it the comment pointing at it. + delete bundle[`${chunk.fileName}.map`] + chunk.code = chunk.code.replace(/\n?\/\/# sourceMappingURL=.*$/, '\n') + trimmed++ + continue + } + + if (!parsed.sourcesContent?.[index]) { + // A silent miss would quietly put the 85 KB back into every published package. + this.error(`${map.fileName} maps a glue but carries no source to drop`) + } + // Our own sources are in here too, so only the glue's copy of itself goes. + parsed.sourcesContent[index] = null + map.source = JSON.stringify(parsed) + trimmed++ } - if (!dropped) { - // A silent miss would quietly put the 85 KB back into every published package. - this.error('the glue source was not found in any sourcemap, so nothing was dropped') + // One for the entry's inlined glue, one for the host glue chunk. + if (trimmed !== 2) { + this.error(`expected to trim the glue out of 2 sourcemaps, trimmed ${trimmed}`) } }, }, diff --git a/src/index.ts b/src/index.ts index 06b2efb..b3f5360 100755 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export { type EmscriptenPath, type EnvironmentVariables, type LuaEmscriptenModule, + type LuaFileSystem, type LuaModuleOptions, } from './module' export { default as LuaTypeExtension } from './type-extension' diff --git a/src/module.ts b/src/module.ts index 3cc2207..69c26c9 100755 --- a/src/module.ts +++ b/src/module.ts @@ -9,6 +9,18 @@ import version from 'package-version' export type EnvironmentVariables = Record +/** + * Which filesystem the Lua states see. + * + * - `'memory'`: Emscripten's in-memory filesystem, holding nothing but `/tmp`, `/home` and `/dev`. + * Identical in every environment, and reaches nothing on the host until a directory is handed to + * it through {@link LuaModuleOptions.mounts}. + * - `'host'`: the real filesystem, through Node. Paths, the working directory, symlinks and + * permissions are the host's own, because every operation goes straight to `node:fs`. Node only, + * and no sandbox: Lua can read and write anything the process can. + */ +export type LuaFileSystem = 'memory' | 'host' + /** Emscripten's own path helpers, which work on the virtual filesystem rather than the host one. */ export interface EmscriptenPath { isAbs: (path: string) => boolean @@ -47,7 +59,10 @@ export interface LuaEmscriptenModule extends EmscriptenModule { _realloc: (pointer: number, size: number) => number } -/** The virtual filesystem every state on a runtime shares, as {@link LuaModule.emscripten}'s `FS`. */ +/** + * The filesystem every state on a runtime shares, as {@link LuaModule.emscripten}'s `FS`. In memory + * unless the module was loaded with `fs: 'host'`, where the same API acts on the real one. + */ export type EmscriptenFS = LuaEmscriptenModule['FS'] export interface LuaModuleOptions { @@ -58,10 +73,16 @@ export interface LuaModuleOptions { wasmFile?: string | undefined /** Environment variables for the Lua states. */ env?: EnvironmentVariables | undefined - /** File system that should be used. */ - fs?: 'node' | 'memory' | undefined - /** Host directories to mount when `fs` is `'node'`. Defaults to the drive holding the CWD. */ - fsMountPaths?: string[] | undefined + /** Which filesystem the Lua states see. Defaults to `'memory'`. */ + fs?: LuaFileSystem | undefined + /** + * Host directories to expose in the in-memory filesystem, keyed by the absolute path Lua sees. + * `{ '/scripts': './lua' }` makes `./lua/init.lua` readable and writable as `/scripts/init.lua` + * and leaves the rest of the host unreachable. Node only, and only with `fs: 'memory'`. + * + * Mount points cannot nest, and a host path has to name a directory that already exists. + */ + mounts?: Readonly> | undefined /** Called once per read. An empty string (or nothing) means EOF. */ stdin?: (() => string | null | undefined) | undefined /** @@ -138,6 +159,122 @@ interface ReferenceMetadata { refCount: number } +/** A {@link LuaModuleOptions.mounts} entry with both sides checked, ready for `FS.mount`. */ +interface ResolvedMount { + /** Absolute, with no trailing slash and no `.` or `..` in it. */ + virtualPath: string + /** Absolute host path of a directory that exists. */ + hostPath: string +} + +// The only rule about `mounts` that is not about a single mount, so both the option and the method +// report it with one voice. +const MOUNTS_NEED_MEMORY_FS = `mounts belong to fs: 'memory'; with 'host' every host path is already reachable` + +/** + * Synchronous and lazy, which is what lets a mount be added after loading without the load path + * paying for a `node:fs` import it usually has no use for. Null anywhere but Node. + */ +function nodeBuiltin(name: string): T | null { + return (globalThis.process?.getBuiltinModule?.(name) as T | undefined) ?? null +} + +/** + * Nothing here is left to fail later: an unreadable host path, or a mount point Emscripten would + * put somewhere unexpected, is an error at once rather than a directory that silently turns up empty. + */ +function resolveMount(virtualPath: string, hostPath: string): ResolvedMount { + const where = `cannot mount '${hostPath}' at '${virtualPath}'` + + const fs = nodeBuiltin('node:fs') + const path = nodeBuiltin('node:path') + if (!fs || !path) { + throw new Error(`${where}: reaching a host directory needs node:fs, which only Node has`) + } + + // Absolute, at least one segment deep, and nothing that would move the mount elsewhere. Not + // normalized on the caller's behalf, so a path that means something else is refused rather than + // quietly changed. + const mountPoint = virtualPath.replace(/\/+$/, '') + if (!/^(\/[^/]+)+$/.test(mountPoint)) { + throw new Error(`${where}: the mount point has to be an absolute path below the root, such as '/scripts'`) + } + if (mountPoint.split('/').some((segment) => segment === '.' || segment === '..')) { + throw new Error(`${where}: the mount point has to be a normalized path, with no '.' or '..' segments`) + } + + if (hostPath.startsWith('~')) { + // Expanded by a shell rather than by node:path, so it would resolve to a directory named + // '~' below the cwd -- which is never what the caller meant. + throw new Error(`${where}: '~' is not expanded, so pass an absolute path (os.homedir() and a join)`) + } + const root = path.resolve(hostPath) + + let isDirectory: boolean + try { + isDirectory = fs.statSync(root).isDirectory() + } catch (err) { + throw new Error(`${where}: '${root}' could not be read`, { cause: err }) + } + if (!isDirectory) { + // NODEFS mounts lazily and would not notice until the first lookup inside it failed. + throw new Error(`${where}: '${root}' is not a directory`) + } + + return { virtualPath: mountPoint, hostPath: root } +} + +/** + * A mount inside another one would have its mount point created on the host it is mounted from, so + * the two have to be disjoint. Checked against the mounts that are already live, which is what makes + * the rule the same whether a mount arrives with the options or afterwards. + */ +function assertMountFits(live: readonly ResolvedMount[], candidate: ResolvedMount): void { + // The trailing slash on both sides keeps '/scripts2' from counting as being inside '/scripts', + // and an equal pair matches in both directions, so a mount point is never claimed twice. + const overlapping = live.find( + (mount) => + `${candidate.virtualPath}/`.startsWith(`${mount.virtualPath}/`) || + `${mount.virtualPath}/`.startsWith(`${candidate.virtualPath}/`), + ) + if (overlapping) { + throw new Error(`cannot mount at '${candidate.virtualPath}': it overlaps the mount point '${overlapping.virtualPath}'`) + } +} + +/** Everything the wasm module needs before it starts, and nothing that outlives that. */ +interface PreRunConfig { + env: EnvironmentVariables | undefined + mounts: readonly ResolvedMount[] + stdin: LuaModuleOptions['stdin'] + stdout: LuaModuleOptions['stdout'] + stderr: LuaModuleOptions['stderr'] +} + +/** + * Built from a record rather than closed over `initialize`'s scope, because Emscripten keeps + * `Module.preRun` for the module's lifetime and a closure would pin everything around it with it. + */ +function createPreRun(config: PreRunConfig): (initializedModule: LuaEmscriptenModule) => void { + return (initializedModule: LuaEmscriptenModule): void => { + if (typeof config.env === 'object') { + Object.assign(initializedModule.ENV, config.env) + } + + // Nothing to do for `fs: 'host'`: that glue is NODERAWFS, so the filesystem already is the + // host's, working directory included. + for (const { virtualPath, hostPath } of config.mounts) { + const fs = initializedModule.FS + fs.mkdirTree(virtualPath) + fs.mount(fs.filesystems.NODEFS, { root: hostPath }, virtualPath) + } + + if (config.stdin || config.stdout || config.stderr) { + initializedModule.FS.init(createInputReader(config.stdin), createOutputWriter(config.stdout), createOutputWriter(config.stderr)) + } + } +} + export default class LuaModule { public static async initialize(opts: Readonly = {}): Promise { const warn = opts.onWarn ?? defaultWarnHandler @@ -145,7 +282,25 @@ export default class LuaModule { // Node with a DOM bolted on, and asking about `window` gets those wrong. const isNode = typeof globalThis.process?.versions?.node === 'string' - const fs = isNode && opts.fs === 'node' ? await import('node:fs') : null + const fs = opts.fs ?? 'memory' + const mountEntries = Object.entries(opts.mounts ?? {}) + + if (fs === 'host' && !isNode) { + throw new Error(`fs: 'host' is the real filesystem, which only Node has; elsewhere use the default 'memory' one`) + } + if (fs === 'host' && mountEntries.length > 0) { + throw new Error(MOUNTS_NEED_MEMORY_FS) + } + + // Resolved before the wasm is instantiated, so a mount that cannot work fails the load + // rather than leaving a module behind. Each one is checked against those already accepted, + // which is the same rule LuaModule.mount applies to a later arrival. + const mounts: ResolvedMount[] = [] + for (const [virtualPath, hostPath] of mountEntries) { + const mount = resolveMount(virtualPath, hostPath) + assertMountFits(mounts, mount) + mounts.push(mount) + } // Emscripten reports load failures itself, which is noise while one can still be recovered // from below, so they are held until the outcome is known. Null once handed over. @@ -158,74 +313,23 @@ export default class LuaModule { } } - const preRun = (initializedModule: LuaEmscriptenModule): void => { - if (typeof opts?.env === 'object') { - Object.assign(initializedModule.ENV, opts.env) - } - - if (fs) { - const cwd = process.cwd().replace(/\\/g, '/') - // Default to mounting the drive containing the CWD - const cwdDrive = process.platform === 'win32' ? cwd.slice(0, 3) : '/' - const mountPaths = opts.fsMountPaths ?? [cwdDrive] - - // Deduplicate and remove paths that are children of other mount paths - const normalized = [...new Set(mountPaths.map((p) => p.replace(/\\/g, '/')))] - const filtered = normalized.filter((path) => !normalized.some((other) => other !== path && path.startsWith(other + '/'))) - - // Expand drive roots into their subdirectories, since - // Emscripten's VFS already owns "/" and cannot be mounted over. - // Skip virtual/system filesystems that cause issues with NODEFS. - const skipDirs = ['dev', 'proc', 'sys', 'run', 'snap', 'System Volume Information', '$Recycle.Bin', 'Recovery'] - const expanded: string[] = [] - for (const dir of filtered) { - const isDriveRoot = dir === '/' || /^[A-Za-z]:\/$/.test(dir) - if (isDriveRoot) { - try { - const children = fs - .readdirSync(dir) - .filter((child: string) => !skipDirs.includes(child)) - .map((child: string) => (dir === '/' ? `/${child}` : `${dir}${child}`.replace(/\\/g, '/'))) - expanded.push(...children) - } catch { - // drive not readable - } - } else { - expanded.push(dir) - } - } + const preRun = createPreRun({ env: opts.env, mounts, stdin: opts.stdin, stdout: opts.stdout, stderr: opts.stderr }) - for (const dir of expanded) { - try { - const moduleFS = initializedModule.FS - moduleFS.mkdirTree(dir) - moduleFS.mount(moduleFS.filesystems.NODEFS, { root: dir }, dir) - } catch (err: any) { - // Ignore permission/not-found errors from both Node.js - // (string .code) and Emscripten ErrnoError (numeric .errno) - const isIgnorableError = ['EACCES', 'EPERM', 'ENOENT'].includes(err?.code) || err?.name === 'ErrnoError' - if (!isIgnorableError) { - warn(`Failed to mount ${dir}`, err) - } - } - } - - try { - initializedModule.FS.chdir(cwd) - } catch { - // CWD may not be within mounted paths - } - } - - if (opts.stdin || opts.stdout || opts.stderr) { - initializedModule.FS.init(createInputReader(opts.stdin), createOutputWriter(opts.stdout), createOutputWriter(opts.stderr)) - } + // Two glues over the one wasm, differing only in their filesystem (see + // utils/build-wasm.sh). The host one is imported on demand, both because it throws outside + // Node and so that a browser bundle leaves it in a chunk it never loads. + const init = fs === 'host' ? (await import('../build/host/glue.js')).default : initWasmModule + if (typeof init !== 'function') { + // The `browser` field points bundlers at a stub, since the glue only runs under Node. + throw new Error(`fs: 'host' needs glue-host.js, which the bundle replaced with a stub because it targets the browser`) } const load = async (wasmFile?: string): Promise => { return new LuaModule( - await initWasmModule({ ...(wasmFile === undefined ? {} : { locateFile: () => wasmFile }), preRun, printErr }), + await init({ ...(wasmFile === undefined ? {} : { locateFile: () => wasmFile }), preRun, printErr }), opts.onWarn, + fs, + mounts, ) } @@ -274,6 +378,8 @@ export default class LuaModule { public emscripten: LuaEmscriptenModule /** The handler passed to {@link LuaModule.initialize}, which states created on it inherit. */ public readonly onWarn: LuaWarnHandler | undefined + /** Which filesystem this module was loaded with, as {@link LuaModuleOptions.fs}. */ + public readonly fs: LuaFileSystem public luaL_checkversion_: (L: LuaAddress, ver: number, sz: number) => void public luaL_getmetafield: (L: LuaAddress, obj: number, e: string | null) => LuaType @@ -430,6 +536,9 @@ export default class LuaModule { public luaopen_package: (L: LuaAddress) => number public luaL_openlibs: (L: LuaAddress) => void + /** The mounts that are live, so a later one can be checked against them. */ + private readonly mounts: ResolvedMount[] + private readonly cStringCache = new Map() private readonly trampolineModules = new Map() private referenceTracker = new WeakMap() @@ -450,9 +559,16 @@ export default class LuaModule { public readonly resultCountScratch: number private stringBuffer = 0 - public constructor(module: LuaEmscriptenModule, onWarn?: LuaWarnHandler) { + public constructor( + module: LuaEmscriptenModule, + onWarn?: LuaWarnHandler, + fs: LuaFileSystem = 'memory', + mounts: readonly ResolvedMount[] = [], + ) { this.emscripten = module this.onWarn = onWarn + this.fs = fs + this.mounts = [...mounts] this.luaL_checkversion_ = this.cwrap('luaL_checkversion_', null, ['number', 'number', 'number']) this.luaL_getmetafield = this.cwrap('luaL_getmetafield', 'number', ['number', 'number', 'string']) @@ -614,6 +730,35 @@ export default class LuaModule { } } + /** + * Exposes a host directory in the in-memory filesystem, the way {@link LuaModuleOptions.mounts} + * does at load time and with the same checks. Node only, and only with `fs: 'memory'`. + */ + public mount(virtualPath: string, hostPath: string): void { + if (this.fs === 'host') { + throw new Error(MOUNTS_NEED_MEMORY_FS) + } + + const mount = resolveMount(virtualPath, hostPath) + assertMountFits(this.mounts, mount) + + const fs = this.emscripten.FS + fs.mkdirTree(mount.virtualPath) + fs.mount(fs.filesystems.NODEFS, { root: mount.hostPath }, mount.virtualPath) + this.mounts.push(mount) + } + + /** Detaches a {@link LuaModule.mount}, leaving its mount point behind as an empty directory. */ + public unmount(virtualPath: string): void { + this.emscripten.FS.unmount(virtualPath) + // Dropped only once Emscripten agrees it was a mount point, so a failed unmount leaves the + // bookkeeping matching what is actually mounted. + const index = this.mounts.findIndex((mount) => mount.virtualPath === virtualPath) + if (index >= 0) { + this.mounts.splice(index, 1) + } + } + /** * Bytes that aren't valid UTF-8 are replaced with U+FFFD. Use {@link lua_tobytes} when the * value holds arbitrary binary data (`string.dump`, `string.pack`, ciphertext, ...). diff --git a/src/runtime.ts b/src/runtime.ts index 527a879..e60de6a 100755 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -41,15 +41,57 @@ export default class LuaRuntime { } /** - * Writes a file into the Lua environment, creating its directories as needed. - * @param path - Path to the file in the Lua environment. - * @param content - Content of the file to be mounted. + * Writes a file the Lua states can read, creating its directories as needed. A relative path is + * resolved against {@link cwd}. + * + * With `fs: 'host'`, and inside a mount, this writes to the real filesystem. */ - public mountFile(path: string, content: string | ArrayBufferView): void { - this.filesystem.mkdirTree(this.path.dirname(path)) + public writeFile(path: string, content: string | ArrayBufferView): void { + // Only when it is missing: mkdirTree walks every level and finds out by way of a thrown + // EEXIST, which costs several times the write itself once the directory is there -- and a + // real syscall per level with `fs: 'host'`. + const parent = this.path.dirname(path) + if (!this.exists(parent)) { + this.filesystem.mkdirTree(parent) + } this.filesystem.writeFile(path, content) } + /** The bytes of a file, for content that is not text. */ + public readFile(path: string): Uint8Array { + return this.filesystem.readFile(path) + } + + /** A file decoded as UTF-8, with invalid bytes replaced by U+FFFD. */ + public readTextFile(path: string): string { + return this.filesystem.readFile(path, { encoding: 'utf8' }) + } + + public exists(path: string): boolean { + return this.filesystem.analyzePath(path).exists + } + + /** Where relative paths resolve from, `/` unless something changed it or `fs` is `'host'`. */ + public cwd(): string { + return this.filesystem.cwd() + } + + /** With `fs: 'host'` this moves the Node process itself, since there is only one working directory. */ + public chdir(path: string): void { + this.filesystem.chdir(path) + } + + /** See {@link LuaModuleOptions.mounts}, whose entries this is the after-the-fact equivalent of. */ + public mount(virtualPath: string, hostPath: string): void { + this.module.mount(virtualPath, hostPath) + } + + /** See {@link LuaModule.unmount}. */ + public unmount(virtualPath: string): void { + this.module.unmount(virtualPath) + } + + /** Emscripten's own filesystem API, for everything the methods above do not cover. */ public get filesystem(): EmscriptenFS { return this.module.emscripten.FS } diff --git a/test/browser.test.js b/test/browser.test.js index aa28806..df3a134 100644 --- a/test/browser.test.js +++ b/test/browser.test.js @@ -180,7 +180,7 @@ describe('Browser environment', () => { this.timeout(30_000) const result = await runInBrowser(` const lua = await LuaRuntime.load({ wasmFile }) - lua.mountFile('mymodule.lua', 'return 42') + lua.writeFile('mymodule.lua', 'return 42') const state = lua.createState() return await state.doString('return require("mymodule")') `) diff --git a/test/bundling.test.js b/test/bundling.test.js index 5eec36e..05010f6 100644 --- a/test/bundling.test.js +++ b/test/bundling.test.js @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -23,9 +24,17 @@ describe('Bundling', () => { const bundle = await rolldown({ input: join(DIST_DIR, 'index.js'), - external: ['node:module', 'node:fs'], + external: ['node:module'], + }) + // A directory rather than a single file, because the host filesystem glue stays a chunk of + // its own -- which is also how a consumer has to bundle us when they target Node. + await bundle.write({ + dir: tempDir, + entryFileNames: 'index.js', + chunkFileNames: 'glue-host.js', + format: 'esm', + minify: true, }) - await bundle.write({ file: join(tempDir, 'index.js'), format: 'esm', minify: true }) await bundle.close() minified = await import(join(tempDir, 'index.js')) @@ -42,6 +51,19 @@ describe('Bundling', () => { return lua.createState({ inject: true, ...config }) } + it('the published host glue is the one the browser field stubs out', async () => { + // The name lives in three places that nothing else connects: rolldown's chunkFileNames, the + // `browser` field, and the import in module.ts. A rename in one of them would either ship + // the node-only glue into browser bundles or break fs: 'host', both of them quietly. + const pkg = JSON.parse(await readFile(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')) + const stubbed = Object.keys(pkg.browser).filter((key) => key.includes('glue')) + + expect(stubbed).to.have.lengthOf(1) + expect(pkg.browser[stubbed[0]]).to.be.false + expect(join(DIST_DIR, stubbed[0].replace('./dist/', ''))).to.satisfy(existsSync) + expect(await readFile(join(DIST_DIR, 'index.js'), 'utf8')).to.include(stubbed[0].replace('./dist/', './')) + }) + it('minifying the bundle renames the unwind classes', async () => { // The premise of the tests below: a check by class name has nothing left to match on. const code = await readFile(join(tempDir, 'index.js'), 'utf8') diff --git a/test/filesystem.test.js b/test/filesystem.test.js index 3846d72..19da1a8 100644 --- a/test/filesystem.test.js +++ b/test/filesystem.test.js @@ -2,9 +2,9 @@ import { expect } from 'chai' import { getState, getLua } from './utils.js' describe('Filesystem', () => { - it('mount a file and require inside lua should succeed', async () => { + it('write a file and require inside lua should succeed', async () => { const lua = await getLua() - lua.mountFile('test.lua', 'answerToLifeTheUniverseAndEverything = 42') + lua.writeFile('test.lua', 'answerToLifeTheUniverseAndEverything = 42') const state = lua.createState() await state.doString('require("test")') @@ -12,9 +12,9 @@ describe('Filesystem', () => { expect(state.get('answerToLifeTheUniverseAndEverything')).to.be.equal(42) }) - it('mount a file in a complex directory and require inside lua should succeed', async () => { + it('write a file in a complex directory and require inside lua should succeed', async () => { const lua = await getLua() - lua.mountFile('yolo/sofancy/test.lua', 'return 42') + lua.writeFile('yolo/sofancy/test.lua', 'return 42') const state = lua.createState() const value = await state.doString('return require("yolo/sofancy/test")') @@ -22,9 +22,9 @@ describe('Filesystem', () => { expect(value).to.be.equal(42) }) - it('mount a init file and require the module inside lua should succeed', async () => { + it('write a init file and require the module inside lua should succeed', async () => { const lua = await getLua() - lua.mountFile('hello/init.lua', 'return 42') + lua.writeFile('hello/init.lua', 'return 42') const state = lua.createState() const value = await state.doString('return require("hello")') @@ -32,48 +32,89 @@ describe('Filesystem', () => { expect(value).to.be.equal(42) }) - it('require a file which is not mounted should throw', async () => { + it('require a file which was not written should throw', async () => { using state = await getState() await expect(state.doString('require("nothing")')).to.eventually.be.rejectedWith(/module 'nothing' not found/) }) - it('mount a file and run it should succeed', async () => { + it('write a file and run it should succeed', async () => { const lua = await getLua() const state = lua.createState() - lua.mountFile('init.lua', `return 42`) + lua.writeFile('init.lua', `return 42`) const value = await state.doFile('init.lua') expect(value).to.be.equal(42) }) - it('run a file which is not mounted should throw', async () => { + it('run a file which was not written should throw', async () => { using state = await getState() await expect(state.doFile('init.lua')).to.eventually.be.rejectedWith(/cannot open init\.lua/) }) - it('mount a file with binary content should succeed', async () => { + it('write a file with binary content should succeed', async () => { const lua = await getLua() using state = lua.createState() - lua.mountFile('binary.lua', new Uint8Array([114, 101, 116, 117, 114, 110, 32, 52, 50])) // "return 42" + lua.writeFile('binary.lua', new Uint8Array([114, 101, 116, 117, 114, 110, 32, 52, 50])) // "return 42" expect(await state.doString('return dofile("binary.lua")')).to.be.equal(42) }) - it('doFileSync should run a mounted file synchronously', async () => { + it('doFileSync should run a written file synchronously', async () => { const lua = await getLua() - lua.mountFile('sync.lua', 'return 5 * 5') + lua.writeFile('sync.lua', 'return 5 * 5') using state = lua.createState() expect(state.doFileSync('sync.lua')).to.be.equal(25) }) + it('the default filesystem should be the in-memory one', async () => { + const lua = await getLua() + + expect(lua.module.fs).to.be.equal('memory') + // Nothing of the host is reachable, and relative paths resolve from the root. + expect(lua.cwd()).to.be.equal('/') + }) + + it('readFile should return what lua wrote', async () => { + const lua = await getLua() + using state = lua.createState() + + await state.doString(` + local f = io.open("/written.txt", "w") + f:write("from lua") + f:close() + `) + + expect(lua.readTextFile('/written.txt')).to.be.equal('from lua') + expect(lua.readFile('/written.txt')).to.be.deep.equal(new Uint8Array([102, 114, 111, 109, 32, 108, 117, 97])) + }) + + it('exists should report both a written and a missing file', async () => { + const lua = await getLua() + lua.writeFile('/present.lua', 'return 1') + + expect(lua.exists('/present.lua')).to.be.true + expect(lua.exists('/absent.lua')).to.be.false + }) + + it('chdir should move where relative paths resolve from', async () => { + const lua = await getLua() + lua.writeFile('/somewhere/mod.lua', 'return "found"') + using state = lua.createState() + + lua.chdir('/somewhere') + + expect(lua.cwd()).to.be.equal('/somewhere') + expect(await state.doFile('mod.lua')).to.be.equal('found') + }) + it('filesystem should expose the shared emscripten FS', async () => { const lua = await getLua() - lua.mountFile('fs-probe.lua', 'return 1') + lua.writeFile('fs-probe.lua', 'return 1') expect(lua.filesystem.analyzePath('/fs-probe.lua').exists).to.be.true }) @@ -85,12 +126,12 @@ describe('Filesystem', () => { expect(lua.path.basename('/a/b/c.lua')).to.be.equal('c.lua') }) - it('mount a file with a large content should succeed', async () => { + it('write a file with a large content should succeed', async () => { const lua = await getLua() const state = lua.createState() const content = 'a'.repeat(1000000) - lua.mountFile('init.lua', `local a = "${content}" return a`) + lua.writeFile('init.lua', `local a = "${content}" return a`) const value = await state.doFile('init.lua') expect(value).to.be.equal(content) diff --git a/test/host-filesystem.test.js b/test/host-filesystem.test.js new file mode 100644 index 0000000..51c007d --- /dev/null +++ b/test/host-filesystem.test.js @@ -0,0 +1,341 @@ +import { readFileSync, writeFileSync, mkdirSync, existsSync, symlinkSync, realpathSync } from 'node:fs' +import { join } from 'node:path' +import { expect } from 'chai' +import { LuaRuntime } from '../dist/index.js' +import { luaPath, readFileFromLua, useTempDirs } from './utils.js' + +describe('Host filesystem', () => { + // fs: 'host' has no working directory of its own -- chdir moves the Node process, so a test that + // moves into a temp directory has to come back. Registered before useTempDirs so it runs before + // the directory the process may be sitting in is removed. + const originalCwd = process.cwd() + afterEach(() => process.chdir(originalCwd)) + + const createTempDir = useTempDirs('host') + let tempDir + let lua + let state + + beforeEach(async () => { + tempDir = createTempDir() + lua = await LuaRuntime.load({ fs: 'host' }) + state = lua.createState() + }) + + afterEach(() => lua.close()) + + /** Absolute host path of `name` inside the temp directory, as a Lua string literal. */ + const at = (...name) => luaPath(join(tempDir, ...name)) + + it('read a host file should succeed', () => { + writeFileSync(join(tempDir, 'hello.txt'), 'hello world') + + expect(readFileFromLua(state, at('hello.txt'))).to.be.equal('hello world') + }) + + it('write a file to host should succeed', async () => { + await state.doString(` + local f = io.open("${at('output.txt')}", "w") + f:write("written from lua") + f:close() + `) + + expect(readFileSync(join(tempDir, 'output.txt'), 'utf8')).to.be.equal('written from lua') + }) + + it('append to a host file should succeed', async () => { + writeFileSync(join(tempDir, 'append.txt'), 'first line\n') + + await state.doString(` + local f = io.open("${at('append.txt')}", "a") + f:write("second line\\n") + f:close() + `) + + expect(readFileSync(join(tempDir, 'append.txt'), 'utf8')).to.be.equal('first line\nsecond line\n') + }) + + it('read file line by line should succeed', async () => { + writeFileSync(join(tempDir, 'lines.txt'), 'alpha\nbeta\ngamma\n') + + const result = await state.doString(` + local lines = {} + for line in io.lines("${at('lines.txt')}") do + lines[#lines + 1] = line + end + return table.concat(lines, ",") + `) + + expect(result).to.be.equal('alpha,beta,gamma') + }) + + it('read binary file should succeed', async () => { + writeFileSync(join(tempDir, 'binary.dat'), Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe])) + + const result = await state.doString(` + local f = io.open("${at('binary.dat')}", "rb") + local data = f:read("*a") + f:close() + return #data + `) + + expect(result).to.be.equal(5) + }) + + it('write and read back binary data should succeed', async () => { + await state.doString(` + local f = io.open("${at('binout.dat')}", "wb") + f:write(string.char(72, 101, 108, 108, 111)) + f:close() + `) + + expect(readFileSync(join(tempDir, 'binout.dat'), 'utf8')).to.be.equal('Hello') + }) + + it('check if file exists using io.open should succeed', async () => { + writeFileSync(join(tempDir, 'exists.txt'), 'yes') + + const result = await state.doString(` + local f = io.open("${at('exists.txt')}", "r") + local exists = f ~= nil + if f then f:close() end + + local f2 = io.open("${at('nope.txt')}", "r") + local not_exists = f2 == nil + if f2 then f2:close() end + + return exists and not_exists + `) + + expect(result).to.be.true + }) + + it('read file size should succeed', async () => { + writeFileSync(join(tempDir, 'sized.txt'), 'abcdefghij') + + const result = await state.doString(` + local f = io.open("${at('sized.txt')}", "r") + local size = f:seek("end") + f:close() + return size + `) + + expect(result).to.be.equal(10) + }) + + it('seek within a file should succeed', async () => { + writeFileSync(join(tempDir, 'seek.txt'), 'abcdefghij') + + const result = await state.doString(` + local f = io.open("${at('seek.txt')}", "r") + f:seek("set", 3) + local chunk = f:read(4) + f:close() + return chunk + `) + + expect(result).to.be.equal('defg') + }) + + it('doFile from host filesystem should succeed', async () => { + writeFileSync(join(tempDir, 'script.lua'), 'return 7 * 6') + + expect(await state.doFile(at('script.lua'))).to.be.equal(42) + }) + + it('require a host lua file should succeed', async () => { + const luaDir = join(tempDir, 'lualibs') + mkdirSync(luaDir, { recursive: true }) + writeFileSync(join(luaDir, 'mymod.lua'), 'local M = {}; M.value = 99; return M') + + using injected = lua.createState({ inject: true }) + const result = await injected.doString(` + package.path = "${at('lualibs')}/?.lua;" .. package.path + local m = require("mymod") + return m.value + `) + + expect(result).to.be.equal(99) + }) + + it('read from nested directories should succeed', () => { + const nested = join(tempDir, 'a', 'b', 'c') + mkdirSync(nested, { recursive: true }) + writeFileSync(join(nested, 'deep.txt'), 'deep content') + + expect(readFileFromLua(state, join(nested, 'deep.txt'))).to.be.equal('deep content') + }) + + it('read through a host symlink should succeed', () => { + // A mirror of the host namespace resolves an absolute symlink target against itself rather + // than against the host, so this only works when the filesystem really is the host's. + writeFileSync(join(tempDir, 'target.txt'), 'behind the link') + symlinkSync(join(tempDir, 'target.txt'), join(tempDir, 'absolute.link')) + symlinkSync('target.txt', join(tempDir, 'relative.link')) + + expect(readFileFromLua(state, at('absolute.link'))).to.be.equal('behind the link') + expect(readFileFromLua(state, at('relative.link'))).to.be.equal('behind the link') + }) + + it('create directory from lua with os.execute should succeed', async () => { + await state.doString(`os.execute("mkdir -p '${at('newdir')}'")`) + + expect(existsSync(join(tempDir, 'newdir'))).to.be.true + }) + + it('remove a host file from lua with os.remove should succeed', async () => { + writeFileSync(join(tempDir, 'removeme.txt'), 'to be deleted') + + expect(await state.doString(`return os.remove("${at('removeme.txt')}")`)).to.be.true + expect(existsSync(join(tempDir, 'removeme.txt'))).to.be.false + }) + + it('rename a host file from lua with os.rename should succeed', async () => { + writeFileSync(join(tempDir, 'old.txt'), 'rename me') + + const result = await state.doString(`return os.rename("${at('old.txt')}", "${at('new.txt')}")`) + + expect(result).to.be.true + expect(existsSync(join(tempDir, 'old.txt'))).to.be.false + expect(readFileSync(join(tempDir, 'new.txt'), 'utf8')).to.be.equal('rename me') + }) + + it('the working directory should be the host process one', () => { + expect(lua.cwd()).to.be.equal(process.cwd()) + }) + + it('a relative path should resolve against the host working directory', async () => { + writeFileSync(join(tempDir, 'relative.lua'), 'return "from cwd"') + + lua.chdir(tempDir) + + // realpath, because the temp directory sits behind a symlink on macOS and the working + // directory is always the resolved one. + expect(lua.cwd()).to.be.equal(realpathSync(tempDir)) + // chdir moved the process, so Node agrees on where "here" is. + expect(process.cwd()).to.be.equal(realpathSync(tempDir)) + expect(await state.doFile('relative.lua')).to.be.equal('from cwd') + }) + + it('writeFile should write to the real filesystem', async () => { + lua.writeFile(at('nested', 'written.lua'), 'return "written"') + + expect(readFileSync(join(tempDir, 'nested', 'written.lua'), 'utf8')).to.be.equal('return "written"') + expect(await state.doFile(at('nested', 'written.lua'))).to.be.equal('written') + }) + + it('write large file should succeed', async () => { + const lineCount = 10000 + + await state.doString(` + local f = io.open("${at('large.txt')}", "w") + for i = 1, ${lineCount} do + f:write("line " .. i .. "\\n") + end + f:close() + `) + + const lines = readFileSync(join(tempDir, 'large.txt'), 'utf8').trimEnd().split('\n') + expect(lines.length).to.be.equal(lineCount) + expect(lines[0]).to.be.equal('line 1') + expect(lines[lineCount - 1]).to.be.equal(`line ${lineCount}`) + }) + + it('overwrite existing file should succeed', async () => { + writeFileSync(join(tempDir, 'overwrite.txt'), 'original content') + + await state.doString(` + local f = io.open("${at('overwrite.txt')}", "w") + f:write("new content") + f:close() + `) + + expect(readFileSync(join(tempDir, 'overwrite.txt'), 'utf8')).to.be.equal('new content') + }) + + it('read UTF-8 content should succeed', () => { + const content = 'héllo wörld 日本語 🌍' + writeFileSync(join(tempDir, 'utf8.txt'), content) + + expect(readFileFromLua(state, at('utf8.txt'))).to.be.equal(content) + }) + + it('write UTF-8 content should succeed', async () => { + await state.doString(` + local f = io.open("${at('utf8out.txt')}", "w") + f:write("café ñ 中文") + f:close() + `) + + expect(readFileSync(join(tempDir, 'utf8out.txt'), 'utf8')).to.be.equal('café ñ 中文') + }) + + it('read empty file should succeed', async () => { + writeFileSync(join(tempDir, 'empty.txt'), '') + + const result = await state.doString(` + local f = io.open("${at('empty.txt')}", "r") + local data = f:read("*a") + f:close() + return #data + `) + + expect(result).to.be.equal(0) + }) + + it('open non-existent file for reading should return nil and a message', async () => { + const result = await state.doString(` + local f, err = io.open("${at('nonexistent.txt')}", "r") + return f == nil and type(err) == "string" and #err > 0 + `) + + expect(result).to.be.true + }) + + it('io.tmpfile should succeed', async () => { + const result = await state.doString(` + local f = io.tmpfile() + f:write("temp data") + f:seek("set") + local data = f:read("*a") + f:close() + return data + `) + + expect(result).to.be.equal('temp data') + }) + + it('multiple states sharing the same filesystem should succeed', async () => { + using other = lua.createState() + + await state.doString(` + local f = io.open("${at('shared.txt')}", "w") + f:write("from state1") + f:close() + `) + + expect(readFileFromLua(other, at('shared.txt'))).to.be.equal('from state1') + }) + + it('read number from file should succeed', async () => { + writeFileSync(join(tempDir, 'numbers.txt'), '42\n3.14\n100') + + const result = await state.doString(` + local f = io.open("${at('numbers.txt')}", "r") + local n1 = f:read("*n") + local n2 = f:read("*n") + local n3 = f:read("*n") + f:close() + return n1 + n2 + n3 + `) + + expect(result).to.be.closeTo(145.14, 0.001) + }) + + it('mounts should be rejected, since every host path is already reachable', async () => { + await expect(LuaRuntime.load({ fs: 'host', mounts: { '/scripts': tempDir } })).to.eventually.be.rejectedWith( + /mounts belong to fs: 'memory'/, + ) + expect(() => lua.mount('/scripts', tempDir)).to.throw(/mounts belong to fs: 'memory'/) + }) +}) diff --git a/test/luatests.js b/test/luatests.js index e873287..c75a8a8 100644 --- a/test/luatests.js +++ b/test/luatests.js @@ -6,7 +6,7 @@ const lua = await LuaRuntime.load() const testsPath = import.meta.resolve('../lua/testes') const filePath = fileURLToPath(typeof testsPath === 'string' ? testsPath : await Promise.resolve(testsPath)) -if (!lua.filesystem.analyzePath('/dev/full').exists) { +if (!lua.exists('/dev/full')) { const deviceMode = lua.filesystem.lookupPath('/dev/null').node.mode const fullDevice = lua.filesystem.makedev(64, 0) lua.filesystem.registerDevice(fullDevice, { @@ -29,7 +29,7 @@ if (!lua.filesystem.analyzePath('/dev/full').exists) { for await (const file of glob(`${filePath}/**/*.lua`)) { const relativeFile = file.replace(`${filePath}/`, '') - lua.mountFile(relativeFile, await readFile(file)) + lua.writeFile(relativeFile, await readFile(file)) } const state = lua.createState() diff --git a/test/mounts.test.js b/test/mounts.test.js new file mode 100644 index 0000000..1c3bf46 --- /dev/null +++ b/test/mounts.test.js @@ -0,0 +1,141 @@ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { expect } from 'chai' +import { LuaRuntime } from '../dist/index.js' +import { readFileFromLua, useTempDirs } from './utils.js' + +describe('Mounts', () => { + const createTempDir = useTempDirs('mounts') + let hostDir + let outsideDir + + beforeEach(() => { + hostDir = createTempDir() + outsideDir = createTempDir() + writeFileSync(join(hostDir, 'inside.txt'), 'mounted content') + writeFileSync(join(outsideDir, 'outside.txt'), 'should stay unreachable') + }) + + it('the in-memory filesystem alone should reach nothing on the host', async () => { + const lua = await LuaRuntime.load() + using state = lua.createState() + + expect(readFileFromLua(state, join(hostDir, 'inside.txt'))).to.be.equal('BLOCKED') + }) + + it('a mount should expose the host directory at the given path', async () => { + const lua = await LuaRuntime.load({ mounts: { '/scripts': hostDir } }) + using state = lua.createState() + + expect(readFileFromLua(state, '/scripts/inside.txt')).to.be.equal('mounted content') + }) + + it('a mount should leave the rest of the host unreachable', async () => { + const lua = await LuaRuntime.load({ mounts: { '/scripts': hostDir } }) + using state = lua.createState() + + expect(readFileFromLua(state, join(outsideDir, 'outside.txt'))).to.be.equal('BLOCKED') + // Not even the host path the mount points at, since only the mount point leads there. + expect(readFileFromLua(state, join(hostDir, 'inside.txt'))).to.be.equal('BLOCKED') + }) + + it('a write through a mount should land on the host', async () => { + const lua = await LuaRuntime.load({ mounts: { '/scripts': hostDir } }) + using state = lua.createState() + + state.doStringSync(` + local f = io.open("/scripts/written.txt", "w") + f:write("from lua") + f:close() + `) + + expect(readFileSync(join(hostDir, 'written.txt'), 'utf8')).to.be.equal('from lua') + }) + + it('several mounts should coexist with files written to memory', async () => { + const other = createTempDir() + writeFileSync(join(other, 'other.txt'), 'the other one') + + const lua = await LuaRuntime.load({ mounts: { '/a': hostDir, '/b/deeper': other } }) + using state = lua.createState() + lua.writeFile('/virtual/only.txt', 'in memory') + + expect(readFileFromLua(state, '/a/inside.txt')).to.be.equal('mounted content') + expect(readFileFromLua(state, '/b/deeper/other.txt')).to.be.equal('the other one') + expect(readFileFromLua(state, '/virtual/only.txt')).to.be.equal('in memory') + }) + + it('a relative host path should resolve against the process working directory', async () => { + const lua = await LuaRuntime.load({ mounts: { '/here': '.' } }) + using state = lua.createState() + + expect(readFileFromLua(state, '/here/package.json')).to.match(/"name": "wasmoon"/) + }) + + it('require should find a module inside a mount', async () => { + mkdirSync(join(hostDir, 'lib'), { recursive: true }) + writeFileSync(join(hostDir, 'lib', 'mymod.lua'), 'return { value = 7 }') + + const lua = await LuaRuntime.load({ mounts: { '/scripts': hostDir } }) + using state = lua.createState({ inject: true }) + + expect( + state.doStringSync(` + package.path = "/scripts/lib/?.lua;" .. package.path + return require("mymod").value + `), + ).to.be.equal(7) + }) + + it('mounting after loading should expose the directory', async () => { + const lua = await LuaRuntime.load() + using state = lua.createState() + + expect(readFileFromLua(state, '/late/inside.txt')).to.be.equal('BLOCKED') + + lua.mount('/late', hostDir) + + expect(readFileFromLua(state, '/late/inside.txt')).to.be.equal('mounted content') + }) + + it('unmounting should make the directory unreachable again, and free the mount point', async () => { + const lua = await LuaRuntime.load({ mounts: { '/scripts': hostDir } }) + using state = lua.createState() + + lua.unmount('/scripts') + + expect(readFileFromLua(state, '/scripts/inside.txt')).to.be.equal('BLOCKED') + + // The mount point is no longer taken, so the same path can be mounted again. + lua.mount('/scripts', outsideDir) + expect(readFileFromLua(state, '/scripts/outside.txt')).to.be.equal('should stay unreachable') + }) + + describe('validation', () => { + // Each case is the same call with a different mounts object, so they are listed rather than + // written out: [what is wrong, mounts, the message it has to be reported with]. + const cases = [ + ['a host directory that does not exist', () => ({ '/scripts': join(hostDir, 'missing') }), /could not be read/], + ['a host path that is a file', () => ({ '/scripts': join(hostDir, 'inside.txt') }), /is not a directory/], + ['a mount point that is not absolute', () => ({ scripts: hostDir }), /has to be an absolute path below the root/], + ['the root as a mount point', () => ({ '/': hostDir }), /has to be an absolute path below the root/], + ['an empty segment in the mount point', () => ({ '//scripts': hostDir }), /has to be an absolute path below the root/], + ['a mount point that is not normalized', () => ({ '/scripts/../etc': hostDir }), /has to be a normalized path/], + ['nested mount points', () => ({ '/scripts': hostDir, '/scripts/inner': outsideDir }), /it overlaps the mount point/], + ['the same mount point twice', () => ({ '/scripts': hostDir, '/scripts/': outsideDir }), /it overlaps the mount point/], + ['a host path starting with ~', () => ({ '/home': '~/wasmoon' }), /'~' is not expanded/], + ] + + for (const [what, mounts, message] of cases) { + it(`${what} should throw`, async () => { + await expect(LuaRuntime.load({ mounts: mounts() })).to.eventually.be.rejectedWith(message) + }) + } + + it('a mount that overlaps one already made should throw', async () => { + const lua = await LuaRuntime.load({ mounts: { '/scripts': hostDir } }) + + expect(() => lua.mount('/scripts/inner', outsideDir)).to.throw(/it overlaps the mount point '\/scripts'/) + }) + }) +}) diff --git a/test/nodefs.test.js b/test/nodefs.test.js deleted file mode 100644 index 6c85826..0000000 --- a/test/nodefs.test.js +++ /dev/null @@ -1,442 +0,0 @@ -import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { expect } from 'chai' -import { LuaRuntime } from '../dist/index.js' -import { luaPath } from './utils.js' - -const createTempDir = () => mkdtempSync(join(tmpdir(), 'wasmoon-nodefs-')) - -// The sentinel distinguishes "empty" from "not reachable at all", which a bare read cannot. -const readFileFromLua = (state, path) => { - return state.doString(` - local f = io.open("${luaPath(path)}", "r") - if not f then return "BLOCKED" end - local content = f:read("*a") - f:close() - return content - `) -} - -describe('Node FS', () => { - let tempDir - - beforeEach(() => { - tempDir = createTempDir() - }) - - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }) - }) - - it('read a host file should succeed', async () => { - writeFileSync(join(tempDir, 'hello.txt'), 'hello world') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await readFileFromLua(state, join(tempDir, 'hello.txt')) - - expect(result).to.be.equal('hello world') - }) - - it('write a file to host should succeed', async () => { - const filePath = join(tempDir, 'output.txt') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - await state.doString(` - local f = io.open("${luaPath(filePath)}", "w") - f:write("written from lua") - f:close() - `) - - expect(readFileSync(filePath, 'utf8')).to.be.equal('written from lua') - }) - - it('append to a host file should succeed', async () => { - const filePath = join(tempDir, 'append.txt') - writeFileSync(filePath, 'first line\n') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - await state.doString(` - local f = io.open("${luaPath(filePath)}", "a") - f:write("second line\\n") - f:close() - `) - - expect(readFileSync(filePath, 'utf8')).to.be.equal('first line\nsecond line\n') - }) - - it('read file line by line should succeed', async () => { - writeFileSync(join(tempDir, 'lines.txt'), 'alpha\nbeta\ngamma\n') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await state.doString(` - local lines = {} - for line in io.lines("${luaPath(join(tempDir, 'lines.txt'))}") do - lines[#lines + 1] = line - end - return table.concat(lines, ",") - `) - - expect(result).to.be.equal('alpha,beta,gamma') - }) - - it('read binary file should succeed', async () => { - const buf = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe]) - writeFileSync(join(tempDir, 'binary.dat'), buf) - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await state.doString(` - local f = io.open("${luaPath(join(tempDir, 'binary.dat'))}", "rb") - local data = f:read("*a") - f:close() - return #data - `) - - expect(result).to.be.equal(5) - }) - - it('write and read back binary data should succeed', async () => { - const filePath = join(tempDir, 'binout.dat') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - await state.doString(` - local f = io.open("${luaPath(filePath)}", "wb") - f:write(string.char(72, 101, 108, 108, 111)) - f:close() - `) - - expect(readFileSync(filePath, 'utf8')).to.be.equal('Hello') - }) - - it('check if file exists using io.open should succeed', async () => { - writeFileSync(join(tempDir, 'exists.txt'), 'yes') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await state.doString(` - local f = io.open("${luaPath(join(tempDir, 'exists.txt'))}", "r") - local exists = f ~= nil - if f then f:close() end - - local f2 = io.open("${luaPath(join(tempDir, 'nope.txt'))}", "r") - local not_exists = f2 == nil - if f2 then f2:close() end - - return exists and not_exists - `) - - expect(result).to.be.true - }) - - it('read file size should succeed', async () => { - writeFileSync(join(tempDir, 'sized.txt'), 'abcdefghij') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await state.doString(` - local f = io.open("${luaPath(join(tempDir, 'sized.txt'))}", "r") - local size = f:seek("end") - f:close() - return size - `) - - expect(result).to.be.equal(10) - }) - - it('seek within a file should succeed', async () => { - writeFileSync(join(tempDir, 'seek.txt'), 'abcdefghij') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await state.doString(` - local f = io.open("${luaPath(join(tempDir, 'seek.txt'))}", "r") - f:seek("set", 3) - local chunk = f:read(4) - f:close() - return chunk - `) - - expect(result).to.be.equal('defg') - }) - - it('doFile from host filesystem should succeed', async () => { - writeFileSync(join(tempDir, 'script.lua'), 'return 7 * 6') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await state.doFile(luaPath(join(tempDir, 'script.lua'))) - - expect(result).to.be.equal(42) - }) - - it('require a host lua file should succeed', async () => { - const luaDir = join(tempDir, 'lualibs') - mkdirSync(luaDir, { recursive: true }) - writeFileSync(join(luaDir, 'mymod.lua'), 'local M = {}; M.value = 99; return M') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState({ inject: true }) - const result = await state.doString(` - package.path = "${luaPath(luaDir)}/?.lua;" .. package.path - local m = require("mymod") - return m.value - `) - - expect(result).to.be.equal(99) - }) - - it('read from nested directories should succeed', async () => { - const nested = join(tempDir, 'a', 'b', 'c') - mkdirSync(nested, { recursive: true }) - writeFileSync(join(nested, 'deep.txt'), 'deep content') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await readFileFromLua(state, join(nested, 'deep.txt')) - - expect(result).to.be.equal('deep content') - }) - - it('create directory from lua with os.execute should succeed', async () => { - const newDir = join(tempDir, 'newdir') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - await state.doString(` - os.execute("mkdir -p '${luaPath(newDir)}'") - `) - - expect(existsSync(newDir)).to.be.true - }) - - it('remove a host file from lua with os.remove should succeed', async () => { - const filePath = join(tempDir, 'removeme.txt') - writeFileSync(filePath, 'to be deleted') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await state.doString(` - return os.remove("${luaPath(filePath)}") - `) - - expect(result).to.be.true - expect(existsSync(filePath)).to.be.false - }) - - it('rename a host file from lua with os.rename should succeed', async () => { - const oldPath = join(tempDir, 'old.txt') - const newPath = join(tempDir, 'new.txt') - writeFileSync(oldPath, 'rename me') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await state.doString(` - return os.rename("${luaPath(oldPath)}", "${luaPath(newPath)}") - `) - - expect(result).to.be.true - expect(existsSync(oldPath)).to.be.false - expect(readFileSync(newPath, 'utf8')).to.be.equal('rename me') - }) - - it('NODEFS should start in the host process cwd', async () => { - const lua = await LuaRuntime.load({ fs: 'node' }) - - expect(lua.filesystem.cwd()).to.be.equal(process.cwd().replace(/\\/g, '/')) - }) - - it('doFile from cwd-relative path should succeed', async () => { - const lua = await LuaRuntime.load({ fs: 'node' }) - // chdir first, or the mounted file lands in the repo the suite runs from. - lua.filesystem.chdir(tempDir) - lua.mountFile('cwd_test.lua', 'return "from cwd"') - const state = lua.createState() - - expect(existsSync(join(tempDir, 'cwd_test.lua'))).to.be.true - expect(await state.doFile('cwd_test.lua')).to.be.equal('from cwd') - }) - - it('fsMountPaths should mount the listed path', async () => { - writeFileSync(join(tempDir, 'inside.txt'), 'mounted content') - - const lua = await LuaRuntime.load({ fs: 'node', fsMountPaths: [tempDir] }) - const state = lua.createState() - - expect(await readFileFromLua(state, join(tempDir, 'inside.txt'))).to.be.equal('mounted content') - }) - - it('fsMountPaths should leave everything else unreachable', async () => { - // Without this the default mounts the whole drive, so the case above passes either way. - const unmounted = createTempDir() - writeFileSync(join(unmounted, 'outside.txt'), 'should not be readable') - - try { - const lua = await LuaRuntime.load({ fs: 'node', fsMountPaths: [tempDir] }) - const state = lua.createState() - - expect(await readFileFromLua(state, join(unmounted, 'outside.txt'))).to.be.equal('BLOCKED') - } finally { - rmSync(unmounted, { recursive: true, force: true }) - } - }) - - it('mountFile and NODEFS should coexist', async () => { - writeFileSync(join(tempDir, 'host.txt'), 'from host') - - const lua = await LuaRuntime.load({ fs: 'node' }) - lua.filesystem.chdir(tempDir) - lua.mountFile('virtual.lua', 'return "from virtual"') - const state = lua.createState() - - expect(await readFileFromLua(state, join(tempDir, 'host.txt'))).to.be.equal('from host') - expect(await state.doString('return dofile("virtual.lua")')).to.be.equal('from virtual') - }) - - it('write large file should succeed', async () => { - const filePath = join(tempDir, 'large.txt') - const lineCount = 10000 - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - await state.doString(` - local f = io.open("${luaPath(filePath)}", "w") - for i = 1, ${lineCount} do - f:write("line " .. i .. "\\n") - end - f:close() - `) - - const content = readFileSync(filePath, 'utf8') - const lines = content.trimEnd().split('\n') - expect(lines.length).to.be.equal(lineCount) - expect(lines[0]).to.be.equal('line 1') - expect(lines[lineCount - 1]).to.be.equal(`line ${lineCount}`) - }) - - it('overwrite existing file should succeed', async () => { - const filePath = join(tempDir, 'overwrite.txt') - writeFileSync(filePath, 'original content') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - await state.doString(` - local f = io.open("${luaPath(filePath)}", "w") - f:write("new content") - f:close() - `) - - expect(readFileSync(filePath, 'utf8')).to.be.equal('new content') - }) - - it('read UTF-8 content should succeed', async () => { - const content = 'héllo wörld 日本語 🌍' - writeFileSync(join(tempDir, 'utf8.txt'), content) - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await readFileFromLua(state, join(tempDir, 'utf8.txt')) - - expect(result).to.be.equal(content) - }) - - it('write UTF-8 content should succeed', async () => { - const filePath = join(tempDir, 'utf8out.txt') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - await state.doString(` - local f = io.open("${luaPath(filePath)}", "w") - f:write("café ñ 中文") - f:close() - `) - - expect(readFileSync(filePath, 'utf8')).to.be.equal('café ñ 中文') - }) - - it('read empty file should succeed', async () => { - writeFileSync(join(tempDir, 'empty.txt'), '') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await state.doString(` - local f = io.open("${luaPath(join(tempDir, 'empty.txt'))}", "r") - local data = f:read("*a") - f:close() - return #data - `) - - expect(result).to.be.equal(0) - }) - - it('open non-existent file for reading should return nil and a message', async () => { - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await state.doString(` - local f, err = io.open("${luaPath(join(tempDir, 'nonexistent.txt'))}", "r") - return f == nil and type(err) == "string" and #err > 0 - `) - - expect(result).to.be.true - }) - - it('io.tmpfile should succeed', async () => { - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await state.doString(` - local f = io.tmpfile() - f:write("temp data") - f:seek("set") - local data = f:read("*a") - f:close() - return data - `) - - expect(result).to.be.equal('temp data') - }) - - it('multiple states sharing the same NODEFS should succeed', async () => { - const filePath = join(tempDir, 'shared.txt') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state1 = lua.createState() - const state2 = lua.createState() - - await state1.doString(` - local f = io.open("${luaPath(filePath)}", "w") - f:write("from state1") - f:close() - `) - - const result = await state2.doString(` - local f = io.open("${luaPath(filePath)}", "r") - local data = f:read("*a") - f:close() - return data - `) - - expect(result).to.be.equal('from state1') - }) - - it('read number from file should succeed', async () => { - writeFileSync(join(tempDir, 'numbers.txt'), '42\n3.14\n100') - - const lua = await LuaRuntime.load({ fs: 'node' }) - const state = lua.createState() - const result = await state.doString(` - local f = io.open("${luaPath(join(tempDir, 'numbers.txt'))}", "r") - local n1 = f:read("*n") - local n2 = f:read("*n") - local n3 = f:read("*n") - f:close() - return n1 + n2 + n3 - `) - - expect(result).to.be.closeTo(145.14, 0.001) - }) -}) diff --git a/test/utils.js b/test/utils.js index f0343b9..7650f16 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { LuaRuntime } from '../dist/index.js' export const getLua = (options) => { @@ -19,3 +22,38 @@ export const tick = () => { /** Windows paths cannot go into a Lua string literal as they are. */ export const luaPath = (path) => path.replace(/\\/g, '/') + +/** + * Hands out temporary directories and removes every one of them afterwards, so a test that needs a + * second or third one does not have to carry its own try/finally. + */ +export const useTempDirs = (prefix) => { + const created = [] + const createTempDir = () => { + const dir = mkdtempSync(join(tmpdir(), `wasmoon-${prefix}-`)) + created.push(dir) + return dir + } + + afterEach(() => { + for (const dir of created.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + return createTempDir +} + +/** + * Reads a file from inside Lua. The sentinel distinguishes "empty" from "not reachable at all", + * which a bare read cannot. + */ +export const readFileFromLua = (state, path) => { + return state.doStringSync(` + local f = io.open("${luaPath(path)}", "r") + if not f then return "BLOCKED" end + local content = f:read("*a") + f:close() + return content + `) +} diff --git a/utils/build-wasm.sh b/utils/build-wasm.sh index 9d8db45..75e643d 100755 --- a/utils/build-wasm.sh +++ b/utils/build-wasm.sh @@ -1,24 +1,25 @@ #!/bin/bash -e cd $(dirname $0) -mkdir -p ../build +mkdir -p ../build/host LUA_SRC=$(ls ../lua/*.c | grep -v "luac.c" | grep -v "lua.c" | tr "\n" " ") # Do not add --closure here: it renames properties, which would strip the brand the JS build puts on # the glue's longjmp unwind classes (see rolldown.config.ts) and turn every unwind into a Lua error. -extension="" if [ "$1" == "dev" ]; then - extension="-O0 -g3 -s ASSERTIONS=1 -s SAFE_HEAP=1 -s STACK_OVERFLOW_CHECK=2" + extension=(-O0 -g3 -s ASSERTIONS=1 -s SAFE_HEAP=1 -s STACK_OVERFLOW_CHECK=2) else # TEXTDECODER=1 keeps Emscripten's short string decode path, which -Oz would otherwise drop # in favour of always calling TextDecoder. LuaModule.readString relies on it. - extension="-Oz -fno-inline-functions -s TEXTDECODER=1 -s BINARYEN_EXTRA_PASSES=gufa-optimizing" + extension=(-Oz -fno-inline-functions -s TEXTDECODER=1 -s BINARYEN_EXTRA_PASSES=gufa-optimizing) fi -emcc \ - -lnodefs.js \ - -s WASM=1 $extension -o ../build/glue.js \ +# Everything that is not about the filesystem, shared by both glues below so they can share one +# `glue.wasm`. +COMMON=( + -s WASM=1 + "${extension[@]}" -s EXPORTED_RUNTIME_METHODS="[ 'ccall', \ 'addFunction', \ @@ -37,28 +38,27 @@ emcc \ 'UTF8ToString', \ 'HEAPU8', \ 'HEAPU32' - ]" \ + ]" -s DEFAULT_LIBRARY_FUNCS_TO_INCLUDE="[ '\$FS_mkdirTree', \ '\$PATH', \ '\$PATH_FS' - ]" \ + ]" -s INCOMING_MODULE_JS_API="[ 'locateFile', \ 'preRun', \ 'print', \ 'printErr' \ - ]" \ - -s ENVIRONMENT="web,worker,node" \ - -s MODULARIZE=1 \ - -s ALLOW_TABLE_GROWTH=1 \ - -s EXPORT_NAME="initWasmModule" \ - -s ALLOW_MEMORY_GROWTH=1 \ - -s STRICT=1 \ - -s EXPORT_ES6=1 \ - -s MALLOC=emmalloc \ - -s STACK_SIZE=1MB \ - -s WASM_BIGINT \ + ]" + -s MODULARIZE=1 + -s ALLOW_TABLE_GROWTH=1 + -s EXPORT_NAME="initWasmModule" + -s ALLOW_MEMORY_GROWTH=1 + -s STRICT=1 + -s EXPORT_ES6=1 + -s MALLOC=emmalloc + -s STACK_SIZE=1MB + -s WASM_BIGINT -s EXPORTED_FUNCTIONS="[ '_malloc', \ '_free', \ @@ -210,5 +210,36 @@ emcc \ '_luaopen_debug', \ '_luaopen_package', \ '_luaL_openselectedlibs' \ - ]" \ + ]" +) + +# The default glue, for every environment. Its filesystem is Emscripten's in-memory one, and +# -lnodefs.js adds the NODEFS backend so a host directory can be mounted into it (LuaModuleOptions +# `mounts`). Nothing here reaches the host on its own. +emcc "${COMMON[@]}" \ + -lnodefs.js \ + -s ENVIRONMENT="web,worker,node" \ + -o ../build/glue.js \ ${LUA_SRC} + +# The glue for `filesystem: 'host'`, which Emscripten only supports under Node. NODERAWFS forwards +# every file operation straight to node:fs, so paths, the working directory, symlinks and +# permissions are the host's rather than a mirror of them. NODE_HOST_ENV stays off so the +# environment is still whatever LuaModuleOptions `env` says, as it is in the default glue. +emcc "${COMMON[@]}" \ + -s NODERAWFS=1 \ + -s NODE_HOST_ENV=0 \ + -s ENVIRONMENT="node" \ + -o ../build/host/glue.js \ + ${LUA_SRC} + +# Both glues are the same wasm with a different filesystem bolted on in JS, which is what lets the +# package ship one binary and pick a glue at load time. Checked rather than assumed, because a +# divergence would otherwise surface as a corrupt module for host users only. +if ! cmp -s ../build/glue.wasm ../build/host/glue.wasm; then + echo "build-wasm: the host glue produced a different glue.wasm, so the two cannot share one binary" >&2 + exit 1 +fi +# Dropped so only one copy is published; the host glue resolves 'glue.wasm' next to itself, and the +# bundle puts both of them in dist/ beside the shared binary. +rm ../build/host/glue.wasm From e41bf7f8b025e5935c005bcb12fe6befa3b59f38 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Wed, 29 Jul 2026 12:23:46 -0300 Subject: [PATCH 50/52] perf --- src/module.ts | 113 ++++++++++++++++++++++++-------------------- src/thread.ts | 39 ++++++++++++++- test/api.test.js | 79 ++++++++++++++++++++++++++++++- utils/build-wasm.sh | 1 - 4 files changed, 179 insertions(+), 53 deletions(-) diff --git a/src/module.ts b/src/module.ts index 69c26c9..4e87a74 100755 --- a/src/module.ts +++ b/src/module.ts @@ -33,7 +33,6 @@ export interface EmscriptenPath { /** The instantiated wasm module, as {@link LuaModule.emscripten}. */ export interface LuaEmscriptenModule extends EmscriptenModule { - ccall: typeof ccall addFunction: typeof addFunction removeFunction: typeof removeFunction setValue: typeof setValue @@ -50,8 +49,8 @@ export interface LuaEmscriptenModule extends EmscriptenModule { lengthBytesUTF8: typeof lengthBytesUTF8 stringToUTF8: typeof stringToUTF8 UTF8ToString: (ptr: number, maxBytesToRead?: number, ignoreNul?: boolean) => string - // The scratch space `ccall` uses for its own C string arguments. Unwound by stackRestore - // rather than freed, so it stays correct when a Lua error longjmps out of the call. + // Scratch space for a C string argument, on the wasm stack. Unwound by stackRestore rather + // than freed, so it stays correct when a Lua error longjmps out of the call. stringToUTF8OnStack: (str: string) => number stackSave: () => number stackRestore: (pointer: number) => void @@ -116,6 +115,20 @@ const MAX_UTF8_BYTES_PER_CHAR = 3 const C_STRING_CACHE_LIMIT = 512 const C_STRING_CACHE_MAX_LENGTH = 128 +/** How a marshalled string argument is turned into a pointer. Ordered so `None` is falsy. */ +const enum StringArgument { + None = 0, + /** A fixed name, worth keeping the encoded form of. */ + Cached = 1, + /** A one-off, encoded into scratch space every time so it cannot fill the cache. */ + Uncached = 2, +} + +// Past this a marshalled string is put on the heap and freed as the call returns, rather than on the +// wasm stack: that is 1MB, shared with the Lua calls made through it, and a whole chunk of source +// passed to one of these would overflow it. +const STACK_STRING_LIMIT = 1024 + // Above this, TextEncoder beats copying byte by byte in JS (measured; TEXTDECODER=1 covers the // other direction upstream). const INLINE_ENCODE_LIMIT = 40 @@ -1046,39 +1059,7 @@ export default class LuaModule { // upstream would fall back to ccall -- which rebuilds its converter table, argument array // and return handler on every call. That is most of the cost of the small C API functions, // and these are hot: metatable names, globals, table fields. - if (!argTypes.includes('string|number')) { - return this.wrapWithStringArguments(raw, returnType, argTypes as Emscripten.JSType[]) - } - - return (...args: any[]) => { - const pointersToBeFreed: number[] = [] - const resolvedArgTypes: Emscripten.JSType[] = argTypes.map((argType, i) => { - if (argType === 'string|number') { - if (typeof args[i] === 'number') { - return 'number' - } else { - // because it will be freed later, this can only be used on functions that lua internally copies the string - if (args[i]?.length > 1024) { - const bufferPointer = this.emscripten.stringToNewUTF8(args[i] as string) - args[i] = bufferPointer - pointersToBeFreed.push(bufferPointer) - return 'number' - } else { - return 'string' - } - } - } - return argType - }) - - try { - return this.emscripten.ccall(name, returnType, resolvedArgTypes, args as Emscripten.TypeCompatibleWithC[]) - } finally { - for (const pointer of pointersToBeFreed) { - this.emscripten._free(pointer) - } - } - } + return this.wrapWithStringArguments(raw, returnType, argTypes) } /** @@ -1089,54 +1070,69 @@ export default class LuaModule { private wrapWithStringArguments( raw: (...args: any[]) => any, returnType: Emscripten.JSType | null, - argTypes: Emscripten.JSType[], + argTypes: Array, ): (...args: any[]) => any { const emscripten = this.emscripten const arity = argTypes.length if (arity < 2 || arity > 5) { throw new Error(`wrapWithStringArguments only unrolls arities 2 to 5, not ${arity}`) } - if (argTypes[0] === 'string') { + if (argTypes[0] !== 'number') { // Every binding takes the lua_State there, so that slot is not unrolled below. throw new Error('wrapWithStringArguments does not marshal the first argument') } const returnsString = returnType === 'string' - const [, s1, s2, s3, s4] = argTypes.map((argType) => argType === 'string') + // `string` is a fixed name worth caching; `string|number` is a chunk of source or another + // one-off, which would fill the cache and push the names that dominate these calls out of it. + const [, s1, s2, s3, s4] = argTypes.map((argType) => + argType === 'string' ? StringArgument.Cached : argType === 'string|number' ? StringArgument.Uncached : StringArgument.None, + ) return (a?: any, b?: any, c?: any, d?: any, e?: any): any => { // Only an argument that misses the cache needs scratch space, and the fixed names that // dominate these calls all hit it, so the stack is only touched when it is used. let stack = 0 + // Lazily, so the common path where nothing is long enough to need the heap allocates + // nothing of its own. + let owned: number[] | undefined try { let pointer: number if (s1) { - if ((pointer = this.toCString(b)) >= 0) { + if ((pointer = this.toCString(b, s1)) >= 0) { b = pointer + } else if (b.length > STACK_STRING_LIMIT) { + b = this.ownCString(b, (owned ??= [])) } else { stack ||= emscripten.stackSave() b = emscripten.stringToUTF8OnStack(b) } } if (s2) { - if ((pointer = this.toCString(c)) >= 0) { + if ((pointer = this.toCString(c, s2)) >= 0) { c = pointer + } else if (c.length > STACK_STRING_LIMIT) { + c = this.ownCString(c, (owned ??= [])) } else { stack ||= emscripten.stackSave() c = emscripten.stringToUTF8OnStack(c) } } if (s3) { - if ((pointer = this.toCString(d)) >= 0) { + if ((pointer = this.toCString(d, s3)) >= 0) { d = pointer + } else if (d.length > STACK_STRING_LIMIT) { + d = this.ownCString(d, (owned ??= [])) } else { stack ||= emscripten.stackSave() d = emscripten.stringToUTF8OnStack(d) } } if (s4) { - if ((pointer = this.toCString(e)) >= 0) { + if ((pointer = this.toCString(e, s4)) >= 0) { e = pointer + } else if (e.length > STACK_STRING_LIMIT) { + e = this.ownCString(e, (owned ??= [])) } else { stack ||= emscripten.stackSave() e = emscripten.stringToUTF8OnStack(e) @@ -1149,18 +1145,35 @@ export default class LuaModule { if (stack) { emscripten.stackRestore(stack) } + if (owned) { + // Only reached by a binding Lua copies the string in, which is what lets these be + // freed as the call returns. + for (const allocated of owned) { + emscripten._free(allocated) + } + } } } } /** - * The C string pointer for a marshalled argument, or -1 when it has to go on the wasm stack - * instead. Cached pointers are kept for the lifetime of the module, so they stay valid across - * the reentrant calls a metamethod can make while one of them is still in flight. + * A string too long to put on the wasm stack, on the heap instead and recorded so the call that + * marshalled it can free it as it returns. + */ + private ownCString(value: string, owned: number[]): number { + const pointer = this.emscripten.stringToNewUTF8(value) + owned.push(pointer) + return pointer + } + + /** + * The C string pointer for a marshalled argument, or -1 when the caller has to put it in + * scratch space instead. Cached pointers are kept for the lifetime of the module, so they stay + * valid across the reentrant calls a metamethod can make while one of them is still in flight. */ - private toCString(value: unknown): number { - // Matches ccall: a nullish argument is a null pointer rather than the text "null". A number - // is already one, which is what the `string|number` bindings pass. + private toCString(value: unknown, mode: StringArgument): number { + // A nullish argument is a null pointer rather than the text "null", as it was under ccall. A + // number is already one, which is what the `string|number` bindings pass. if (value === null || value === undefined) { return 0 } @@ -1170,7 +1183,7 @@ export default class LuaModule { // Checked before the lookup, because hashing a long string costs more than the call saves. const text = value as string - if (text.length > C_STRING_CACHE_MAX_LENGTH) { + if (mode === StringArgument.Uncached || text.length > C_STRING_CACHE_MAX_LENGTH) { return -1 } const cached = this.cStringCache.get(text) diff --git a/src/thread.ts b/src/thread.ts index 16e35bf..b9dc484 100755 --- a/src/thread.ts +++ b/src/thread.ts @@ -35,6 +35,10 @@ const INSTRUCTION_HOOK_COUNT = 1000 const LUA_INTEGER_BITS = 64 +// LUAI_MAXSHORTLEN. At or below it Lua interns the string, which is what lets getMetatableName treat +// an address as the identity of a name. +const LUA_MAX_SHORT_STRING = 40 + const NO_RESTORE = (): void => undefined export default class Thread { @@ -45,6 +49,11 @@ export default class Thread { public onWarn: LuaWarnHandler | undefined protected readonly typeExtensions: OrderedExtension[] protected readonly parent: Thread | undefined + /** + * Shared with every thread on the same state, and only ever holding names anchored by that + * state, so it goes when the state does and nothing in it can outlive what it describes. + */ + private readonly metatableNames: Map private closed = false private hookFunctionPointer: number | undefined private hookCount = INSTRUCTION_HOOK_COUNT @@ -56,6 +65,7 @@ export default class Thread { this.typeExtensions = typeExtensions this.address = address this.parent = parent + this.metatableNames = parent ? parent.metatableNames : new Map() } public newThread(): Thread { @@ -304,7 +314,19 @@ export default class Thread { return undefined } - const name = this.module.lua_tolstring(this.address, -1, null) + // Decoding this again on every table and userdata read is most of what getValue spends on + // its dispatch, and it is nearly always one of a handful of extension names. Lua interns a + // short string, so the address of the characters is the identity of the name: an extension's + // own copy is anchored in the registry for as long as the state lives, which both keeps the + // address from being reused and makes it the one every equal name resolves to. + const pointer = this.module.lua_topointer(this.address, -1) + let name = this.metatableNames.get(pointer) + if (name === undefined) { + name = this.module.lua_tolstring(this.address, -1, null) + if (name.length <= LUA_MAX_SHORT_STRING && this.isExtensionName(name)) { + this.metatableNames.set(pointer, name) + } + } // This is popping the luaL_getmetafield result which only pushes with type is not nil. this.pop(1) @@ -598,6 +620,21 @@ export default class Thread { return index > 0 ? index : this.module.lua_absindex(this.address, index) } + /** + * Whether a name belongs to a registered extension, which is what makes it safe to remember the + * address of: those are the only ones the state keeps alive itself. Only reached the first time + * a name is seen. + */ + private isExtensionName(name: string): boolean { + const extensions = this.typeExtensions + for (let i = 0; i < extensions.length; i++) { + if (extensions[i].extension.name === name) { + return true + } + } + return false + } + /** Offers the value to each extension by descending priority. False if none claimed it. */ private pushWithExtension(decoration: Decoration, cache: LuaPushCache | undefined): boolean { const extensions = this.typeExtensions diff --git a/test/api.test.js b/test/api.test.js index e557538..3b64d6a 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { LuaMultiReturn, LuaRawResult, LuaTypeExtension, decorate } from '../dist/index.js' +import { LuaMultiReturn, LuaRawResult, LuaReturn, LuaTypeExtension, decorate } from '../dist/index.js' import { getLua, getState } from './utils.js' describe('MultiReturn', () => { @@ -211,3 +211,80 @@ describe('Thread lifecycle', () => { state.pop() }) }) + +// A lua_Integer is an i64, so pushValue converts a safe integer to a BigInt and anything outside +// that range becomes a float. These cover both ends of the range, and the raw bindings, which take +// the BigInt directly. +describe('Lua integer arguments', () => { + const edges = [0, 1, -1, 2 ** 31, -(2 ** 31), 2 ** 32, Number.MAX_SAFE_INTEGER, -Number.MAX_SAFE_INTEGER] + + it('should push every safe integer exactly, as an integer', async () => { + using state = await getState() + + for (const value of edges) { + state.set('value', value) + + expect(await state.doString('return math.type(value)'), `math.type of ${value}`).to.be.equal('integer') + expect(await state.doString('return value'), `round trip of ${value}`).to.be.equal(value) + } + }) + + it('should push negative zero as the integer zero, the way a bigint does', async () => { + using state = await getState() + state.set('value', -0) + + expect(await state.doString('return math.type(value)')).to.be.equal('integer') + expect(await state.doString('return value == 0')).to.be.equal(true) + }) + + it('should take a raw table index as a bigint', async () => { + using state = await getState() + state.module.lua_createtable(state.address, 3, 0) + + for (const [index, text] of ['a', 'b', 'c'].entries()) { + state.pushValue(text) + state.module.lua_rawseti(state.address, -2, BigInt(index + 1)) + } + state.module.lua_setglobal(state.address, 'letters') + + expect(await state.doString('return table.concat(letters, ",")')).to.be.equal('a,b,c') + }) + + it('should reach an index a double cannot hold exactly through a bigint', async () => { + using state = await getState() + const index = 2n ** 62n + + state.module.lua_createtable(state.address, 0, 1) + state.pushValue('far') + state.module.lua_rawseti(state.address, -2, index) + state.module.lua_setglobal(state.address, 'sparse') + + expect(await state.doString('return sparse[4611686018427387904]')).to.be.equal('far') + }) +}) + +// A marshalled string goes on the wasm stack, which is 1MB and shared with the Lua calls made +// through it, so one longer than that has to be put on the heap instead. +describe('C string arguments', () => { + const OVER_STACK = 1_500_000 + + it('should marshal a name longer than the wasm stack', async () => { + using state = await getState() + const name = `long_${'n'.repeat(OVER_STACK)}` + + // lua_setglobal and lua_getglobal both take the name as a C string. + state.set(name, 'reached') + + expect(state.get(name)).to.be.equal('reached') + }) + + it('should marshal a chunk longer than the wasm stack', async () => { + using state = await getState() + // luaL_loadstring takes the whole chunk as a C string, unlike doString which hands over a + // pointer of its own. + const script = `${'-- padding\n'.repeat(OVER_STACK / 10)}return 'compiled'` + + expect(state.module.luaL_loadstring(state.address, script)).to.be.equal(LuaReturn.Ok) + expect(state.runSync()[0]).to.be.equal('compiled') + }) +}) diff --git a/utils/build-wasm.sh b/utils/build-wasm.sh index 75e643d..c7d5f98 100755 --- a/utils/build-wasm.sh +++ b/utils/build-wasm.sh @@ -21,7 +21,6 @@ COMMON=( -s WASM=1 "${extension[@]}" -s EXPORTED_RUNTIME_METHODS="[ - 'ccall', \ 'addFunction', \ 'removeFunction', \ 'FS', \ From 85cb6e55a15854a26025f30758d587bf32fbdffe Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Wed, 29 Jul 2026 13:06:59 -0300 Subject: [PATCH 51/52] perf --- src/module.ts | 5 ++ src/state.ts | 14 ++++-- src/thread.ts | 71 ++++++++++++---------------- src/type-extension.ts | 62 +++++++++++++++++++----- src/type-extensions/function.ts | 84 ++++++++++++++++++++++++++------- 5 files changed, 164 insertions(+), 72 deletions(-) diff --git a/src/module.ts b/src/module.ts index 4e87a74..107625a 100755 --- a/src/module.ts +++ b/src/module.ts @@ -996,6 +996,11 @@ export default class LuaModule { return this.referenceMap.get(index) } + /** The index {@link ref} already holds for `data`, without taking a count, or undefined. */ + public getRefIndex(data: unknown): number | undefined { + return this.referenceTracker.get(data)?.index + } + // This is needed for some tests public getLastRefIndex(): number | undefined { return this.lastRefIndex diff --git a/src/state.ts b/src/state.ts index d4bab6c..c7a5ca4 100755 --- a/src/state.ts +++ b/src/state.ts @@ -207,6 +207,13 @@ export default class LuaState extends Thread { public registerTypeExtension(priority: number, extension: LuaTypeExtension): void { this.typeExtensions.push({ extension, priority }) this.typeExtensions.sort((a, b) => b.priority - a.priority) + + // The extension's metatable is registry anchored for the life of the state, so from here + // on its address alone identifies its name -- getMetatableName's fast path. + if (this.module.luaL_getmetatable(this.address, extension.name) !== LuaType.Nil) { + this.metatableNames.set(this.getPointer(-1), extension.name) + } + this.pop(1) } /** Retrieves the value of a global variable. */ @@ -295,9 +302,8 @@ export default class LuaState extends Thread { // WARNING: It will not wait for open handles and can potentially cause bugs if JS code tries to reference Lua after executed private async callByteCode(loader: (thread: Thread) => void, options?: LuaRunOptions): Promise { - const thread = this.newThread() - // Move the thread off the global stack and into the registry as a GC anchor so it doesn't pile threads up into the stack - const ref = this.module.luaL_ref(this.address, LUA_REGISTRYINDEX) + // Anchored so the run doesn't pile threads up on the global stack. + const { thread, reference } = this.newAnchoredThread() try { // Seeded from the state so a state wide setLimits reaches the async path too, which // runs on a child thread rather than on the state itself. @@ -306,7 +312,7 @@ export default class LuaState extends Thread { return (await thread.run(0, options))[0] } finally { thread.close() - this.module.luaL_unref(this.address, LUA_REGISTRYINDEX, ref) + this.module.luaL_unref(this.address, LUA_REGISTRYINDEX, reference) } } } diff --git a/src/thread.ts b/src/thread.ts index b9dc484..53f2d9f 100755 --- a/src/thread.ts +++ b/src/thread.ts @@ -5,6 +5,7 @@ import type LuaTypeExtension from './type-extension' import { defaultWarnHandler, LUA_MULTRET, + LUA_REGISTRYINDEX, LuaAbortError, type LuaAddress, LuaError, @@ -35,10 +36,6 @@ const INSTRUCTION_HOOK_COUNT = 1000 const LUA_INTEGER_BITS = 64 -// LUAI_MAXSHORTLEN. At or below it Lua interns the string, which is what lets getMetatableName treat -// an address as the identity of a name. -const LUA_MAX_SHORT_STRING = 40 - const NO_RESTORE = (): void => undefined export default class Thread { @@ -50,10 +47,12 @@ export default class Thread { protected readonly typeExtensions: OrderedExtension[] protected readonly parent: Thread | undefined /** - * Shared with every thread on the same state, and only ever holding names anchored by that - * state, so it goes when the state does and nothing in it can outlive what it describes. + * The address of each registered extension's metatable, mapped to the extension's name. + * Populated by `LuaState.registerTypeExtension` and shared with every thread on the same + * state: those metatables are registry anchored for the state's whole life, so an address in + * here can never be reused by anything else while the map exists. */ - private readonly metatableNames: Map + protected readonly metatableNames: Map private closed = false private hookFunctionPointer: number | undefined private hookCount = INSTRUCTION_HOOK_COUNT @@ -76,6 +75,18 @@ export default class Thread { return new Thread(this.module, this.typeExtensions, address, this.parent || this) } + /** + * A new thread anchored in the registry instead of left on this thread's stack. The reference + * is what keeps it alive: `luaL_unref` it to let the thread go, or keep it forever for a + * thread meant to live as long as the state. + */ + public newAnchoredThread(): { thread: Thread; reference: number } { + const thread = this.newThread() + // Taken on this thread's stack, where newThread left the new one. + const reference = this.module.luaL_ref(this.address, LUA_REGISTRYINDEX) + return { thread, reference } + } + public resetThread(): void { this.assertOk(this.module.lua_resetthread(this.address)) } @@ -303,33 +314,26 @@ export default class Thread { } public getMetatableName(index: number): string | undefined { - const metatableNameType = this.module.luaL_getmetafield(this.address, index, '__name') - if (metatableNameType === LuaType.Nil) { + // Resolving __name on every table and userdata read is most of what getValue spends on its + // dispatch, and it is nearly always one of the extension metatables that registration + // already recorded in metatableNames -- so the common case is these three raw calls and a + // map hit, with no string crossing the boundary in either direction. + if (!this.module.lua_getmetatable(this.address, index)) { return undefined } + const pointer = this.module.lua_topointer(this.address, -1) - if (metatableNameType !== LuaType.String) { - // Pop the metafield if it's not a string + let name = this.metatableNames.get(pointer) + if (name !== undefined) { this.pop(1) - return undefined + return name } - // Decoding this again on every table and userdata read is most of what getValue spends on - // its dispatch, and it is nearly always one of a handful of extension names. Lua interns a - // short string, so the address of the characters is the identity of the name: an extension's - // own copy is anchored in the registry for as long as the state lives, which both keeps the - // address from being reused and makes it the one every equal name resolves to. - const pointer = this.module.lua_topointer(this.address, -1) - let name = this.metatableNames.get(pointer) - if (name === undefined) { + // Anything else reads __name off the metatable that is still on the stack. + if (this.module.lua_getfield(this.address, -1, '__name') === LuaType.String) { name = this.module.lua_tolstring(this.address, -1, null) - if (name.length <= LUA_MAX_SHORT_STRING && this.isExtensionName(name)) { - this.metatableNames.set(pointer, name) - } } - // This is popping the luaL_getmetafield result which only pushes with type is not nil. - this.pop(1) - + this.pop(2) return name } @@ -620,21 +624,6 @@ export default class Thread { return index > 0 ? index : this.module.lua_absindex(this.address, index) } - /** - * Whether a name belongs to a registered extension, which is what makes it safe to remember the - * address of: those are the only ones the state keeps alive itself. Only reached the first time - * a name is seen. - */ - private isExtensionName(name: string): boolean { - const extensions = this.typeExtensions - for (let i = 0; i < extensions.length; i++) { - if (extensions[i].extension.name === name) { - return true - } - } - return false - } - /** Offers the value to each extension by descending priority. False if none claimed it. */ private pushWithExtension(decoration: Decoration, cache: LuaPushCache | undefined): boolean { const extensions = this.typeExtensions diff --git a/src/type-extension.ts b/src/type-extension.ts index 6d5d377..4dcd8fa 100644 --- a/src/type-extension.ts +++ b/src/type-extension.ts @@ -1,17 +1,34 @@ import type { Decoration } from './decoration' import type LuaState from './state' import type Thread from './thread' -import { type LuaAddress, type LuaGetCache, type LuaPushCache, LuaReturn, LuaType, PointerSize } from './types' +import { LUA_REGISTRYINDEX, type LuaAddress, type LuaGetCache, type LuaPushCache, LuaReturn, LuaType, PointerSize } from './types' export default abstract class LuaTypeExtension { // Type name, for metatables and lookups. public readonly name: string /** Owns this extension's metatable and function pointers, so its lifetime bounds theirs. */ protected state: LuaState + /** + * A weak valued table in the registry mapping a reference index to the userdata pushed for it, + * so pushing the same value again returns that userdata instead of allocating another one -- + * which also gives it a stable identity in Lua. Weak, so the cache never keeps a userdata + * alive: an entry goes as soon as Lua drops the userdata, before its `__gc` releases the + * reference index it is keyed by. Per extension, because the same value pushed through two + * extensions must not share a userdata carrying the wrong metatable. + */ + private readonly userdataCacheReference: bigint public constructor(state: LuaState, name: string) { this.state = state this.name = name + + const module = state.module + module.lua_createtable(state.address, 0, 0) + module.lua_createtable(state.address, 0, 1) + module.lua_pushstring(state.address, 'v') + module.lua_setfield(state.address, -2, '__mode') + module.lua_setmetatable(state.address, -2) + this.userdataCacheReference = BigInt(module.luaL_ref(state.address, LUA_REGISTRYINDEX)) } public isType(_thread: Thread, _index: number, type: LuaType, name?: string): boolean { @@ -59,23 +76,46 @@ export default abstract class LuaTypeExtension { // check the type. That must be done by the class extending this. public pushValue(thread: Thread, decoratedValue: Decoration, _cache?: LuaPushCache): boolean { const { target } = decoratedValue + const module = thread.module + + // The cache table stays at the bottom for the whole push, so the probe and the store + // below share the one registry fetch. + module.lua_rawgeti(thread.address, LUA_REGISTRYINDEX, this.userdataCacheReference) - const pointer = thread.module.ref(target) + const existingIndex = module.getRefIndex(target) + if (existingIndex !== undefined) { + if (module.lua_rawgeti(thread.address, -1, BigInt(existingIndex)) === LuaType.Userdata) { + // Drop the cache table, keeping the userdata. + module.lua_remove(thread.address, -2) + return true + } + // Pop the miss; the cache table stays for the store. + thread.pop(1) + } + + const pointer = module.ref(target) // 4 = size of pointer in wasm. - const userDataPointer = thread.module.lua_newuserdatauv(thread.address, PointerSize, 0) - thread.module.writePointer(userDataPointer, pointer) - - if (LuaType.Nil === thread.module.luaL_getmetatable(thread.address, this.name)) { - // Pop the pushed nil value and the user data. The reference has to be released by hand: - // without a metatable the userdata has no __gc, so nothing else ever would. - thread.pop(2) - thread.module.unref(pointer) + const userDataPointer = module.lua_newuserdatauv(thread.address, PointerSize, 0) + module.writePointer(userDataPointer, pointer) + + if (LuaType.Nil === module.luaL_getmetatable(thread.address, this.name)) { + // Pop the pushed nil value, the user data and the cache table. The reference has to be + // released by hand: without a metatable the userdata has no __gc, so nothing else ever + // would. + thread.pop(3) + module.unref(pointer) throw new Error(`metatable not found: ${this.name}`) } // Set as the metatable for the userdata. // -1 is the metatable, -2 is the user data. - thread.module.lua_setmetatable(thread.address, -2) + module.lua_setmetatable(thread.address, -2) + + // Remember the userdata for the next push of the same value, then drop the cache table + // from under it. + module.lua_pushvalue(thread.address, -1) + module.lua_rawseti(thread.address, -3, BigInt(pointer)) + module.lua_remove(thread.address, -2) return true } diff --git a/src/type-extensions/function.ts b/src/type-extensions/function.ts index ebae0f5..350c2f5 100644 --- a/src/type-extensions/function.ts +++ b/src/type-extensions/function.ts @@ -19,6 +19,14 @@ class FunctionTypeExtension extends TypeExtension { private gcPointer: number private functionWrapper: number private callbackContext: Thread + /** + * One call thread kept ready instead of a `lua_newthread` per call, which was most of the cost + * of calling a Lua function from JS. Anchored in the registry rather than on the callback + * context's stack, and handed out only when no other call is using it: a reentrant call (Lua → + * JS → Lua) falls back to a thread of its own. + */ + private readonly pooledCallThread: Thread + private pooledCallThreadInUse = false /** Milliseconds a Lua function called from JS may run before being interrupted. */ private readonly functionTimeout: number | undefined @@ -28,11 +36,10 @@ class FunctionTypeExtension extends TypeExtension { this.functionTimeout = functionTimeout // Create a thread off of the global thread to be used to create function call threads without // interfering with the global context. This creates a callback context that will always exist - // even if the thread that called getValue() has been destroyed. - this.callbackContext = state.newThread() - // Pops it from the global stack but keeps it alive. The reference is never released, for the - // reason given on LuaTypeExtension.close. - this.state.module.luaL_ref(state.address, LUA_REGISTRYINDEX) + // even if the thread that called getValue() has been destroyed. Neither anchor is ever + // released, for the reason given on LuaTypeExtension.close. + this.callbackContext = state.newAnchoredThread().thread + this.pooledCallThread = this.callbackContext.newAnchoredThread().thread if (!this.functionRegistry) { state.warn('FunctionTypeExtension: FinalizationRegistry not found. Memory leaks likely.') @@ -56,9 +63,20 @@ class FunctionTypeExtension extends TypeExtension { this.functionWrapper = state.module.addFunction((calledL: LuaAddress) => { const calledThread = state.stateToThread(calledL) - const refUserdata = state.module.luaL_checkudata(calledL, state.module.lua_upvalueindex(1), this.name) - const refPointer = state.module.readPointer(refUserdata) - const { target, options: decorationOptions } = state.module.getRef(refPointer) as Decoration + // The upvalue is always the userdata pushValue closed this wrapper over, so the + // metatable check luaL_checkudata does (and the name it marshals) is saved on every + // call. Only a ref that does not resolve to a function decoration -- the upvalue + // replaced through the debug library -- falls back to it, for the error the C API + // would have raised. + const upvalueIndex = state.module.lua_upvalueindex(1) + const refUserdata = state.module.lua_touserdata(calledL, upvalueIndex) + const reference = refUserdata ? state.module.getRef(state.module.readPointer(refUserdata)) : undefined + if (!(reference instanceof Decoration) || typeof reference.target !== 'function') { + // Raises the error the C API would have; the throw below is unreachable. + state.module.luaL_checkudata(calledL, upvalueIndex, this.name) + throw new Error('a js_function upvalue does not hold a function reference') + } + const { target, options: decorationOptions } = reference const argsQuantity = calledThread.getTop() const args = [] @@ -107,11 +125,43 @@ class FunctionTypeExtension extends TypeExtension { public close(): void { this.state.module.removeFunction(this.gcPointer) this.state.module.removeFunction(this.functionWrapper) - // Doesn't destroy the Lua thread, just function pointers. The thread itself went with the - // state. + // Doesn't destroy the Lua threads, just function pointers. The threads themselves went + // with the state. + this.pooledCallThread.close() this.callbackContext.close() } + private acquireCallThread(): Thread { + if (this.pooledCallThreadInUse) { + return this.callbackContext.newThread() + } + + this.pooledCallThreadInUse = true + return this.pooledCallThread + } + + private releaseCallThread(callThread: Thread, failed: boolean): void { + if (callThread !== this.pooledCallThread) { + callThread.close() + // Pop thread used for function call. + this.callbackContext.pop() + return + } + + if (failed) { + // A failed call can leave more behind than stack values: a suspended coroutine, or + // pending to-be-closed variables. Resetting closes those, so the thread is always + // reusable afterwards. Not on every release, because a reset also shrinks the stack + // just to regrow it on the next call; and not Thread.resetThread, whose status + // assertion would throw out of the finally this runs in while the call error is + // already propagating -- the setTop clears the error object a failed reset leaves + // behind instead. + this.state.module.lua_resetthread(callThread.address) + } + callThread.setTop(0) + this.pooledCallThreadInUse = false + } + public isType(_thread: Thread, _index: number, type: LuaType): boolean { return type === LuaType.Function } @@ -167,9 +217,10 @@ class FunctionTypeExtension extends TypeExtension { throw new Error('cannot call a Lua function after its state has been closed') } - // Function calls back to value should always be within a new thread because - // they can be left in inconsistent states. - const callThread = this.callbackContext.newThread() + // A call can leave its thread in an inconsistent state, so each one gets a thread that + // is either fresh or has been reset since the last call. + const callThread = this.acquireCallThread() + let failed = false try { const internalType = callThread.module.lua_rawgeti(callThread.address, LUA_REGISTRYINDEX, funcReference) if (internalType !== LuaType.Function) { @@ -198,10 +249,11 @@ class FunctionTypeExtension extends TypeExtension { return callThread.getValue(-1) } return undefined + } catch (err) { + failed = true + throw err } finally { - callThread.close() - // Pop thread used for function call. - this.callbackContext.pop() + this.releaseCallThread(callThread, failed) } } From 67deeb41ccf251917ab2bb0c17b5791af482d043 Mon Sep 17 00:00:00 2001 From: Gabriel Francisco Date: Wed, 29 Jul 2026 14:07:32 -0300 Subject: [PATCH 52/52] fixes --- src/module.ts | 17 +++++- src/state.ts | 33 +++++++++--- src/thread.ts | 100 ++++++++++++++++++++++++++++++++---- test/initialization.test.js | 72 ++++++++++++++++++++++++++ test/limits.test.js | 47 +++++++++++++++++ 5 files changed, 253 insertions(+), 16 deletions(-) diff --git a/src/module.ts b/src/module.ts index 107625a..02a0d23 100755 --- a/src/module.ts +++ b/src/module.ts @@ -570,6 +570,19 @@ export default class LuaModule { private readonly sizeScratch: number /** The `nresults` out parameter for {@link lua_resume}, read by `Thread.resume`. */ public readonly resultCountScratch: number + /** + * The light userdata a run interrupted by the debug hook is unwound with. Nothing but the hook + * ever pushes this address, and no Lua object can live at it, so finding it on the stack is + * proof that the error came from a limit rather than from the script -- see `Thread.assertOk`. + * + * Pushing the error itself is what this replaced: it goes through pushValue, and a state with no + * error extension registered marshals it into a plain table, losing the identity the recovery + * needs. `decorate(error, { as: 'userdata' })` would survive that, but not this: light userdata + * allocates nothing, and the hook fires at an arbitrary instruction, including under a + * `memory.max` tight enough that a real userdata would fail and report a memory error in place + * of the limit that was actually hit. + */ + public readonly interruptToken: number private stringBuffer = 0 public constructor( @@ -738,7 +751,9 @@ export default class LuaModule { this.sizeScratch = module._malloc(PointerSize) this.resultCountScratch = module._malloc(PointerSize) - if (!this.sizeScratch || !this.resultCountScratch) { + // Never read or written, only compared: it exists so that the address is ours alone. + this.interruptToken = module._malloc(1) + if (!this.sizeScratch || !this.resultCountScratch || !this.interruptToken) { throw new Error('failed to allocate the scratch buffers for C out parameters') } } diff --git a/src/state.ts b/src/state.ts index c7a5ca4..8d0b1a8 100755 --- a/src/state.ts +++ b/src/state.ts @@ -218,6 +218,9 @@ export default class LuaState extends Thread { /** Retrieves the value of a global variable. */ public get(name: string): T { + // Without this a read from a closed state returns whatever the freed memory now holds, + // which is the one failure mode with nothing to notice it by. + this.assertNotClosed() const type = this.module.lua_getglobal(this.address, name) const value = this.getValue(-1, type) this.pop() @@ -226,11 +229,13 @@ export default class LuaState extends Thread { /** Sets the value of a global variable. */ public set(name: string, value: unknown): void { + this.assertNotClosed() this.pushValue(value) this.module.lua_setglobal(this.address, name) } public getTable(name: string, callback: (index: number) => void): void { + this.assertNotClosed() const startStackTop = this.getTop() const type = this.module.lua_getglobal(this.address, name) try { @@ -239,11 +244,14 @@ export default class LuaState extends Thread { } callback(startStackTop + 1) } finally { - // +1 for the table - if (this.getTop() !== startStackTop + 1) { - this.warn(`getTable: expected stack size ${startStackTop + 1} got ${this.getTop()}`) + // Nothing to check or rewind on a state the callback closed, the same as the do* paths. + if (!this.isClosed()) { + // +1 for the table + if (this.getTop() !== startStackTop + 1) { + this.warn(`getTable: expected stack size ${startStackTop + 1} got ${this.getTop()}`) + } + this.setTop(startStackTop) } - this.setTop(startStackTop) } } @@ -289,6 +297,10 @@ export default class LuaState extends Thread { } private callByteCodeSync(loader: (thread: Thread) => void, options?: LuaRunOptions): any { + // Ahead of the stack bookkeeping below rather than left to the loader, so that a closed + // state is refused before anything reads or rewinds the freed one. + this.assertNotClosed() + // Runs on the main thread so the script sees itself as the main coroutine, the way the // reference implementation behaves. That makes leftovers outlive the call, hence the rewind. const startStackTop = this.getTop() @@ -296,12 +308,17 @@ export default class LuaState extends Thread { loader(this) return this.runSync(0, options)[0] } finally { - this.setTop(startStackTop) + // Closing runs the state's own teardown, which leaves nothing here to rewind. + if (!this.isClosed()) { + this.setTop(startStackTop) + } } } // WARNING: It will not wait for open handles and can potentially cause bugs if JS code tries to reference Lua after executed private async callByteCode(loader: (thread: Thread) => void, options?: LuaRunOptions): Promise { + this.assertNotClosed() + // Anchored so the run doesn't pile threads up on the global stack. const { thread, reference } = this.newAnchoredThread() try { @@ -312,7 +329,11 @@ export default class LuaState extends Thread { return (await thread.run(0, options))[0] } finally { thread.close() - this.module.luaL_unref(this.address, LUA_REGISTRYINDEX, reference) + // The await above gives anything else a chance to close the state, and lua_close has + // already released the registry this would be unreferencing from. + if (!this.isClosed()) { + this.module.luaL_unref(this.address, LUA_REGISTRYINDEX, reference) + } } } } diff --git a/src/thread.ts b/src/thread.ts index 53f2d9f..301e3a7 100755 --- a/src/thread.ts +++ b/src/thread.ts @@ -46,6 +46,12 @@ export default class Thread { public onWarn: LuaWarnHandler | undefined protected readonly typeExtensions: OrderedExtension[] protected readonly parent: Thread | undefined + /** + * The state this thread belongs to, or itself when it is the state. Resolved once here rather + * than walked on demand: it is what {@link isClosed} and the shared slots below consult, and + * those sit in front of the entry points the interop benchmarks measure. + */ + protected readonly rootThread: Thread /** * The address of each registered extension's metatable, mapped to the extension's name. * Populated by `LuaState.registerTypeExtension` and shared with every thread on the same @@ -58,21 +64,30 @@ export default class Thread { private hookCount = INSTRUCTION_HOOK_COUNT private limits: LuaThreadLimits = {} private instructionsUsed = 0 + /** + * The error the debug hook unwound the current run with. See {@link LuaModule.interruptToken} + * for why it is held here rather than pushed into Lua. Kept on the root thread, because the + * hook fires with whichever thread Lua is running -- a coroutine inherits it -- while the + * {@link assertOk} that reports it is the one the run was started on. + */ + private pendingInterrupt: LuaInterruptError | undefined public constructor(cmodule: LuaModule, typeExtensions: OrderedExtension[], address: number, parent?: Thread) { this.module = cmodule this.typeExtensions = typeExtensions this.address = address this.parent = parent + this.rootThread = parent ?? this this.metatableNames = parent ? parent.metatableNames : new Map() } public newThread(): Thread { + this.assertNotClosed() const address = this.module.lua_newthread(this.address) if (!address) { throw new Error('lua_newthread returned a null pointer') } - return new Thread(this.module, this.typeExtensions, address, this.parent || this) + return new Thread(this.module, this.typeExtensions, address, this.rootThread) } /** @@ -88,11 +103,13 @@ export default class Thread { } public resetThread(): void { + this.assertNotClosed() this.assertOk(this.module.lua_resetthread(this.address)) } /** @param options.mode defaults to `'t'`. See {@link LuaLoadOptions.mode}. */ public loadString(luaCode: string, options?: LuaLoadOptions): void { + this.assertNotClosed() // Lua copies the chunk while loading, so the shared buffer can be handed straight to it // rather than encoded into an allocation of its own. this.assertOk( @@ -104,10 +121,14 @@ export default class Thread { /** @param options.mode defaults to `'t'`. See {@link LuaLoadOptions.mode}. */ public loadFile(filename: string, options?: LuaLoadOptions): void { + this.assertNotClosed() this.assertOk(this.module.luaL_loadfilex(this.address, filename, options?.mode ?? 't')) } public resume(argCount = 0): LuaResumeResult { + // Also covers the resumes `run` makes after an await, where the state can have been closed + // by anything else that got to run in the meantime. + this.assertNotClosed() // The shared slot is safe for the same reason the one behind it is: C writes the count as it // returns and it is read straight after, with nothing interleaved. A nested resume has // finished with the slot by the time this one's lua_resume writes to it. @@ -139,6 +160,7 @@ export default class Thread { } public async run(argCount = 0, options?: LuaRunOptions): Promise { + this.assertNotClosed() const restore = this.applyRunOptions(options) try { let resumeResult: LuaResumeResult = this.resume(argCount) @@ -186,6 +208,7 @@ export default class Thread { } public runSync(argCount = 0, options?: LuaRunOptions): MultiReturn { + this.assertNotClosed() const restore = this.applyRunOptions(options) try { const base = this.getTop() - argCount - 1 // The 1 is for the function to run @@ -201,6 +224,7 @@ export default class Thread { } public call(name: string, ...args: any[]): MultiReturn { + this.assertNotClosed() const type = this.module.lua_getglobal(this.address, name) if (type !== LuaType.Function) { throw new TypeError(`cannot call '${name}': expected a function, got ${LuaType[type]}`) @@ -230,7 +254,7 @@ export default class Thread { if (L === this.address) { return this } - return L === this.parent?.address ? this.parent : new Thread(this.module, this.typeExtensions, L, this.parent || this) + return L === this.parent?.address ? this.parent : new Thread(this.module, this.typeExtensions, L, this.rootThread) } public pushValue(rawValue: unknown, cache?: LuaPushCache): void { @@ -410,6 +434,7 @@ export default class Thread { * They share a single debug hook, because Lua only allows one hook per thread. */ public setLimits(limits: LuaThreadLimits | undefined): void { + this.assertNotClosed() this.limits = { ...limits } this.instructionsUsed = 0 this.applyHook() @@ -425,6 +450,7 @@ export default class Thread { * `LuaRunOptions.timeout` is the per-run equivalent that does take a duration. */ public setDeadline(deadline: number | undefined): void { + this.assertNotClosed() this.limits.deadline = deadline && deadline > 0 ? deadline : undefined this.applyHook() } @@ -439,8 +465,7 @@ export default class Thread { * runtime's rather than straight to the console. */ public warn(message: string, cause?: unknown): void { - const root = this.parent ?? this - ;(root.onWarn ?? this.module.onWarn ?? defaultWarnHandler)(message, cause) + ;(this.rootThread.onWarn ?? this.module.onWarn ?? defaultWarnHandler)(message, cause) } /** For identity checks on values JS cannot represent. */ @@ -449,7 +474,26 @@ export default class Thread { } public isClosed(): boolean { - return !this.address || this.closed || Boolean(this.parent?.isClosed()) + // A thread's parent is always the state, so this is the whole chain rather than one step of + // it, and the guard in front of the benchmarked entry points is three field reads. + return !this.address || this.closed || this.rootThread.closed + } + + /** + * Guards the calls that begin a Lua operation. `lua_close` frees the `lua_State` on the wasm + * heap, so without this one made afterwards reads and writes memory that has been handed back + * to the allocator: it might trap, might return a plausible value, or might corrupt whatever + * now owns those bytes. + * + * The rule is one check per operation, none per stack value: the primitives `getTop`, `setTop`, + * `pop`, `remove`, `pushValue`, `getValue` and the readers around them stay unguarded so an + * extension pays nothing for them, and everything that puts them to work is guarded here. A new + * method belongs on one side or the other of that line. + */ + protected assertNotClosed(): void { + if (this.isClosed()) { + throw new Error('the Lua state is closed') + } } public indexToString(index: number): string { @@ -498,11 +542,18 @@ export default class Thread { return } + const stackTop = this.getTop() + + const interrupt = this.takePendingInterrupt(stackTop) + if (interrupt) { + throw interrupt + } + // This is the default message if there's nothing on the stack. let luaMessage = `Lua Error(${LuaReturn[result]}/${result})` let luaValue: unknown - if (this.getTop() > 0) { + if (stackTop > 0) { if (result === LuaReturn.ErrorMem) { // If there's no memory just do a normal to string. luaMessage = this.module.lua_tolstring(this.address, -1, null) @@ -519,6 +570,9 @@ export default class Thread { } } + // Not the hook's interrupts, which takePendingInterrupt above has already claimed: this is a + // JS function that threw one itself, marshalled back by reference through the extension that + // pushed it. Reported as thrown rather than wrapped, the same as one the hook raised. if (luaValue instanceof LuaInterruptError) { throw luaValue } @@ -543,6 +597,26 @@ export default class Thread { throw new LuaError(result, luaMessage, { traceback, luaValue }) } + /** + * The interrupt the hook unwound the run with, claimed only when the error being reported is + * the token it pushed. A script that pcalled the interrupt away leaves the slot set with the + * token nowhere on the stack, so whatever error did surface is still reported as itself. + */ + private takePendingInterrupt(stackTop: number): LuaInterruptError | undefined { + const root = this.rootThread + if (root.pendingInterrupt === undefined || stackTop === 0) { + return undefined + } + // The token is a heap address the module never hands to Lua, so nothing else can be at it. + if (this.getPointer(-1) !== this.module.interruptToken) { + return undefined + } + + const interrupt = root.pendingInterrupt + root.pendingInterrupt = undefined + return interrupt + } + private applyRunOptions(options: LuaRunOptions | undefined): () => void { const overrides = options !== undefined && @@ -569,6 +643,13 @@ export default class Thread { } private applyHook(): void { + // The restore that `applyRunOptions` hands back runs in a finally, so it reaches here after + // a state closed midway through its own run. Returning rather than throwing, because that + // finally must not replace whatever error is already on its way out. + if (this.isClosed()) { + return + } + const { deadline, maxInstructions, signal } = this.limits if (deadline === undefined && maxInstructions === undefined && signal === undefined) { this.module.lua_sethook(this.address, null, 0, 0) @@ -585,7 +666,8 @@ export default class Thread { // earlier configuration still honours the current one. const error = this.checkHookLimits() if (error) { - this.pushValue(error) + this.rootThread.pendingInterrupt = error + this.module.lua_pushlightuserdata(this.address, this.module.interruptToken) this.module.lua_error(this.address) } }, 'vii') @@ -594,7 +676,7 @@ export default class Thread { this.module.lua_sethook(this.address, this.hookFunctionPointer, LuaEventMasks.Count, this.hookCount) } - private checkHookLimits(): Error | undefined { + private checkHookLimits(): LuaInterruptError | undefined { const { maxInstructions } = this.limits if (maxInstructions !== undefined) { this.instructionsUsed += this.hookCount @@ -605,7 +687,7 @@ export default class Thread { return this.checkYieldLimits() } - private checkYieldLimits(): Error | undefined { + private checkYieldLimits(): LuaInterruptError | undefined { const { deadline, signal } = this.limits if (signal?.aborted) { return new LuaAbortError('thread aborted') diff --git a/test/initialization.test.js b/test/initialization.test.js index 46cf787..3042477 100644 --- a/test/initialization.test.js +++ b/test/initialization.test.js @@ -1,5 +1,6 @@ import { LUA_LIB_BITS, LuaRuntime } from '../dist/index.js' import { expect } from 'chai' +import { getLua, getState } from './utils.js' describe('Initialization', () => { it('create state should succeed', async () => { @@ -155,4 +156,75 @@ describe('Disposal', () => { expect(escaped.isClosed()).to.be.true }) + + // lua_close hands the lua_State back to the wasm allocator, so a call that gets through + // afterwards is reading and writing memory something else may already own. These used to reach + // the bindings: some trapped with `table index is out of bounds`, `get` returned whatever was + // in the freed struct, and `call` reported the global as missing. + describe('use after close', () => { + // The guard is one check per operation and none per stack value, so these are every entry + // point on the guarded side of that line. Split by shape rather than wrapped in a promise, + // to hold each one to throwing the way it does when the state is open. + const syncEntryPoints = { + get: (state) => state.get('print'), + set: (state) => state.set('x', 1), + doStringSync: (state) => state.doStringSync('return 1'), + doFileSync: (state) => state.doFileSync('script.lua'), + call: (state) => state.call('print', 'hi'), + getTable: (state) => state.getTable('_G', () => undefined), + loadString: (state) => state.loadString('return 1'), + loadFile: (state) => state.loadFile('script.lua'), + newThread: (state) => state.newThread(), + resetThread: (state) => state.resetThread(), + resume: (state) => state.resume(), + runSync: (state) => state.runSync(), + setLimits: (state) => state.setLimits({ maxInstructions: 10 }), + setDeadline: (state) => state.setDeadline(Date.now() + 10), + } + + for (const [name, use] of Object.entries(syncEntryPoints)) { + it(`${name} throws on a closed state instead of using freed memory`, async () => { + using state = await getState() + state.close() + + expect(() => use(state)).to.throw('the Lua state is closed') + }) + } + + for (const name of ['doString', 'doFile']) { + it(`${name} rejects on a closed state instead of using freed memory`, async () => { + using state = await getState() + state.close() + + await expect(state[name]('return 1')).to.eventually.be.rejectedWith('the Lua state is closed') + }) + } + + it('a state closed by its runtime is refused the same way', async () => { + const lua = await getLua() + const state = lua.createState() + lua.close() + + expect(() => state.doStringSync('return 1')).to.throw('the Lua state is closed') + }) + + it('a thread outlives neither the state nor the check', async () => { + using state = await getState() + const thread = state.newThread() + state.close() + + expect(thread.isClosed()).to.be.true + expect(() => thread.loadString('return 1')).to.throw('the Lua state is closed') + }) + + it('a state closed while a run is parked stops rather than resuming into freed memory', async () => { + using state = await getState() + state.set('sleep', (ms) => new Promise((resolve) => setTimeout(resolve, ms))) + + const running = state.doString('sleep(10):await() return 1') + state.close() + + await expect(running).to.eventually.be.rejectedWith('the Lua state is closed') + }) + }) }) diff --git a/test/limits.test.js b/test/limits.test.js index 0fb0d32..16f9c16 100644 --- a/test/limits.test.js +++ b/test/limits.test.js @@ -72,4 +72,51 @@ describe('Run limits', () => { expect(state.getLimits().maxInstructions).to.be.equal(999) }) + + // An interrupt used to be pushed into Lua as a JS error and recognised on the way back out by + // its identity, which only survived in a state whose extensions marshal an Error by reference. + // `objects: 'copy'` with `errors: false` -- a sandbox that keeps JS errors away from Lua, and so + // exactly the state most likely to set a limit in the first place -- turned every interrupt into + // an opaque `LuaError: table: 0x...`. + describe('state configurations', () => { + const configs = [ + ['the default proxy state', {}], + ['objects: copy', { objects: 'copy' }], + ['objects: copy with errors off', { objects: 'copy', errors: false }], + ['no injected globals', { inject: false }], + ['no standard libraries', { libs: false }], + ['no libraries and nothing to marshal errors', { libs: false, objects: 'copy', errors: false, inject: false }], + ] + + for (const [description, config] of configs) { + it(`reports every interrupt as itself in ${description}`, async function () { + this.timeout(20_000) + using state = await getState(config) + + await expect(state.doString(busyLoop, { maxInstructions: 5_000 })).to.eventually.be.rejectedWith(LuaInstructionLimitError) + await expect(state.doString(busyLoop, { timeout: 20 })).to.eventually.be.rejectedWith(LuaTimeoutError) + await expect(state.doString(busyLoop, { signal: AbortSignal.abort() })).to.eventually.be.rejectedWith(LuaAbortError) + expect(() => state.doStringSync(busyLoop, { maxInstructions: 5_000 })).to.throw(LuaInstructionLimitError) + }) + } + }) + + it('an interrupt the script caught does not stand in for the error that followed', async function () { + this.timeout(20_000) + using state = await getState({ objects: 'copy', errors: false }) + + // The budget is spent inside the pcall, so the interrupt is raised and swallowed there. The + // hook re-raises within another hookCount instructions, which is room enough for the error + // below and nothing much else. + const failure = state.doString( + `pcall(function () ${busyLoop} end) + error('raised after the interrupt was caught')`, + { maxInstructions: 5_000 }, + ) + + await expect(failure).to.eventually.be.rejected.then((error) => { + expect(error).to.not.be.an.instanceOf(LuaInterruptError) + expect(error.message).to.contain('raised after the interrupt was caught') + }) + }) })