Skip to content
This repository was archived by the owner on Sep 15, 2026. It is now read-only.

Repository files navigation

mquickjs-kmp

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.

Maven Central Platform License

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-malloc footprint. The 0.1.0 artifact stays on Maven Central but receives no further updates.

Why

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.

Platforms

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

Install

commonMain.dependencies {
    implementation("wang.harlon:mquickjs-kmp:latest.version")
}

Usage

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 as JsValue.Json by default.
  • A script that throws, fails to parse, or exhausts its memory raises JsException with the engine's message and stack.
  • Throwing from a host function surfaces in JS as an Error with the Kotlin message.
  • engine.interrupt() may be called from any thread and stops the running script with InternalError: interrupted.
  • The engine is single-threaded; use JsRuntime (below) or serialize access yourself.

Holding JS objects: JsRef

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.

Precompiled bytecode

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.

Typed values: kotlinx.serialization

@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.decodeFromJsValue are the building blocks; JsValue.decode<T>() and JsEngine.evaluateAs<T>() are shortcuts. A JsRef decodes through its toJson().
  • Typed registerFunction takes one to three arguments. A missing argument decodes like undefined (that is, as null), a decoding failure surfaces in JS as an Error, and returning Unit yields undefined.
  • Every entry point takes a json: Json for ignoreUnknownKeys and friends; the default is Json.Default.
  • JsRuntime has the same evaluateAs and typed registerFunction.

Coroutines: JsRuntime

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.

Documentation

Building

  • JDK 25 for the Gradle daemon (gradle/gradle-daemon-jvm.properties; Gradle downloads it when missing), Xcode, Android SDK with the NDK version pinned in gradle/libs.versions.toml, and cmake on PATH.
  • ./gradlew macosArm64Test is the fastest full check (:library:macosArm64Test for the core module alone); testAndroidHostTest runs the same suite through the real JNI bridge on the host; connectedAndroidDeviceTest runs it on a device or emulator.
  • ./gradlew :library:nativeShimTest runs the C-level shim tests under DEBUG_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 to main; publish.yml releases to Maven Central when a version tag is pushed.

Upstream

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.

License

MIT. MQuickJS itself is MIT, copyright Fabrice Bellard and Charlie Gordon.

About

Kotlin Multiplatform bindings for MicroQuickJS, the embedded-systems JavaScript engine by Fabrice Bellard

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages