Kotlin Multiplatform bindings for MicroQuickJS, the JavaScript engine for embedded systems by Fabrice Bellard. Runs a JS program in as little as 10 kB of RAM, instantiates in microseconds, and never calls
malloc.
English | 中文
Archived. Development has moved to quickjs-kmp, the same API on top of full QuickJS (ES2025). Use that unless you specifically need MQuickJS's fixed-buffer, no-
mallocfootprint. The0.1.0artifact stays on Maven Central but receives no further updates.
MQuickJS trades JavaScript coverage for footprint: an ES5-ish strict subset, a compacting GC, a ROM-resident standard library and a fixed memory buffer handed in by the host. That makes it a good fit for rule engines, dynamic configuration expressions and other small, high-frequency scripting on mobile, where QuickJS is heavier than needed. This SDK exposes it to Kotlin Multiplatform with a single Maven coordinate and no native build steps on the consumer side.
| Target | Binding | Notes |
|---|---|---|
Android (minSdk 24) |
JNI | .so bundled in the AAR |
iOS (iosArm64, iosSimulatorArm64) |
cinterop | static library bundled in the klib, no CocoaPods / SPM |
macOS (macosArm64) |
cinterop | debug host for DEBUG_GC / ASan, published as well |
commonMain.dependencies {
implementation("wang.harlon:mquickjs-kmp:latest.version")
}JsEngine(JsEngineConfig(memoryBytes = 128 * 1024, logger = ::println)).use { engine ->
engine.registerFunction("discount") { args ->
val amount = (args[0] as JsValue.Num).value
JsValue.Num(if (amount > 100) amount * 0.9 else amount)
}
engine.evaluate("var total = discount(120);")
engine.evaluate("total") // JsValue.Num(108.0)
engine.evaluate("({ok: total > 100})") // JsValue.Json("{\"ok\":true}")
engine.evaluate("console.log('done', total)") // logger receives "done 108"
}- Primitives cross the boundary as
JsValue.Num/Str/Bool/Null/Undefined; objects and arrays asJsValue.Jsonby default. - A script that throws, fails to parse, or exhausts its memory raises
JsExceptionwith the engine's message andstack. - Throwing from a host function surfaces in JS as an
Errorwith the Kotlin message. engine.interrupt()may be called from any thread and stops the running script withInternalError: interrupted.- The engine is single-threaded; use
JsRuntime(below) or serialize access yourself.
Ask for ObjectTransport.REF and objects come back as live handles instead of JSON. A JsRef reads and writes properties, indexes arrays, calls functions with a this and arguments, and must be closed: the object stays in the engine's fixed memory until then.
JsEngine().use { engine ->
val rules = engine.evaluate("({limit: 3, check: function (n) { return n <= this.limit; }})", objects = ObjectTransport.REF) as JsRef
rules.use { r ->
r.set("limit", JsValue.Num(10))
val check = r.get("check", ObjectTransport.REF) as JsRef
check.use { it.invoke(thisArg = r, args = listOf(JsValue.Num(7))) } // JsValue.Bool(true)
}
}Host functions registered with ObjectTransport.REF receive refs that live only for the duration of the call; retain() keeps one. engine.stats().liveRefs tells you how many refs are still open, which is how the SDK's own tests prove nothing leaks.
JsBytecode.compile turns a script into engine bytecode; JsEngine.loadBytecode loads it without parsing and keeps it outside the engine's fixed memory. Load it before anything else runs on the engine (one program per engine, so bundle your scripts into one), register host functions, then run.
val bytes = JsBytecode.compile(source, "rules.js") // do this at build time or once on device, then cache
JsEngine().use { engine ->
val program = engine.loadBytecode(bytes) // must come before evaluate / registerFunction
engine.registerFunction("report") { it[0] }
program.use { it.run() }
}Bytecode is bound to the engine commit of the SDK that produced it (MQuickJs.upstreamCommit) and to a word size (JsBytecode.wordSize: 64 everywhere except armeabi-v7a); mismatches are rejected with a clear JsException. Nothing else about the bytes is validated, so only load what this SDK compiled. The host tool kmpjsc (./gradlew :library:buildHostTools) does the same from the command line.
@Serializable types cross the boundary with kotlinx.serialization (the runtime ships with the SDK; add the compiler plugin to your own module): primitives become JsValue.Num / Str / Bool / Null, everything else becomes JSON text.
@Serializable data class Order(val id: Int, val items: List<String>)
@Serializable data class Quote(val total: Double, val discounted: Boolean)
JsEngine().use { engine ->
engine.registerFunction("quote") { order: Order ->
Quote(total = order.items.size * 9.5, discounted = order.items.size > 3)
}
val quote: Quote = engine.evaluateAs("quote({id: 1, items: ['a', 'b']})")
val ids: List<Int> = engine.evaluate("[1, 2, 3]").decode()
}Json.encodeToJsValue/Json.decodeFromJsValueare the building blocks;JsValue.decode<T>()andJsEngine.evaluateAs<T>()are shortcuts. AJsRefdecodes through itstoJson().- Typed
registerFunctiontakes one to three arguments. A missing argument decodes likeundefined(that is, asnull), a decoding failure surfaces in JS as anError, and returningUnityieldsundefined. - Every entry point takes a
json: JsonforignoreUnknownKeysand friends; the default isJson.Default. JsRuntimehas the sameevaluateAsand typedregisterFunction.
JsRuntime serializes every access to one engine on a single-lane dispatcher and maps cancellation and timeouts to engine interrupts.
val runtime = JsRuntime()
try {
try {
runtime.evaluate("for (;;) {}", timeout = 200.milliseconds)
} catch (e: TimeoutCancellationException) {
// the script was interrupted; the engine stays usable
}
runtime.withEngine { evaluate("1 + 1") } // exclusive access, refs usable inside
} finally {
runtime.shutdown()
}Exclusion comes from an internal mutex, so any dispatcher works; the default is a single lane of Dispatchers.Default.
- docs/architecture.md: layering, the handle-table design forced by the compacting GC, the host-function trampoline
- docs/native-build.md: upstream vendoring, host tool, per-target builds
- docs/js-subset.md: what the stricter-mode subset forbids and how to handle it on the Kotlin side
- docs/decisions.md: why things are named and scoped the way they are
- docs/roadmap.md: milestones
- JDK 25 for the Gradle daemon (
gradle/gradle-daemon-jvm.properties; Gradle downloads it when missing), Xcode, Android SDK with the NDK version pinned ingradle/libs.versions.toml, andcmakeonPATH. ./gradlew macosArm64Testis the fastest full check (:library:macosArm64Testfor the core module alone);testAndroidHostTestruns the same suite through the real JNI bridge on the host;connectedAndroidDeviceTestruns it on a device or emulator../gradlew :library:nativeShimTestruns the C-level shim tests underDEBUG_GC(every allocation moves objects) and AddressSanitizer.- CI (
.github/workflows/build.yml) runs the shim tests, macOS tests, Android host tests, iOS compilation, Android AAR assembly and the API check on every PR and push tomain;publish.ymlreleases to Maven Central when a version tag is pushed.
The engine is vendored under native/mquickjs with git subtree, pinned to the commit recorded in native/UPSTREAM. MQuickJs.upstreamCommit exposes that commit at runtime.
MIT. MQuickJS itself is MIT, copyright Fabrice Bellard and Charlie Gordon.