
Embed QuickJS for evaluating scripts, ES modules and precompiled bytecode; live JS object handles, Promise draining, interruptible execution, host-function bridging, and serialization-aware typed values.
Kotlin Multiplatform bindings for QuickJS, the ES2025 JavaScript engine by Fabrice Bellard. Android + iOS, with macOS as a debug host.
English | 中文
Successor of mquickjs-kmp (archived); see docs/roadmap.md for what is done and what is next. Built for TinyUI but not tied to it.
| Target | Binding | Notes |
|---|---|---|
Android (minSdk 24) |
JNI |
.so bundled in the AAR (arm64-v8a, armeabi-v7a, x86_64) |
iOS (iosArm64, iosSimulatorArm64) |
cinterop | static library bundled in the klib, no CocoaPods / SPM |
macOS (macosArm64) |
cinterop | debug host for ASan, published as well |
commonMain.dependencies {
implementation("wang.harlon:quickjs-kmp:latest.release")
}JsEngine(JsEngineConfig(memoryLimit = 8L * 1024 * 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("const 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"
}JsValue.Num / Str / Bool / Null / Undefined, BigInt as decimal text in JsValue.BigInt, ArrayBuffer and typed arrays as a copy of their bytes in JsValue.Bytes (a Bytes handed to JS becomes an ArrayBuffer); other objects and arrays as JsValue.Json by default. JSON drops what JSON.stringify drops (functions, undefined properties, Map / Set contents); ask for ObjectTransport.REF when that matters.memoryLimit raises JsException with the engine's message and stack.Error with the Kotlin message.engine.interrupt() may be called from any thread and stops the running script with an uncatchable InternalError: interrupted.JsEngineConfig.maxStackSize (default 256 KB) must stay below the stack of the thread that runs the engine.JsRuntime (below) or serialize access yourself.Every outermost engine call drains the microtask queue before it returns, so then callbacks and await continuations run inside the same call. A Promise result is unwrapped: fulfilled gives its value, rejected throws JsException, still pending comes back as a JsRef whose isPromise is true; the Promise itself may settle during a later call, the flag does not change.
JsEngine(JsEngineConfig(onUnhandledRejection = { e -> println("lost: ${e.message}") })).use { engine ->
engine.evaluate("async function total(a, b) { await null; return a + b; }")
engine.evaluate("total(1, 2)") // JsValue.Num(3.0)
engine.evaluate("Promise.reject(new Error('x'))") // throws JsException("Error: x")
engine.evaluate("Promise.reject(new Error('y')); 0") // returns 0, handler receives "Error: y"
}Rejections nobody handled by the time the call returns go to onUnhandledRejection, or to logger as one line when no handler is set. Interrupting a call also discards the microtasks it left behind.
Modules are looked up in a name table by the exact specifier scripts use: register the sources the page may import, then evaluate the page module and read its namespace. There is no file-system loader and no relative-path resolution, so a bundler must flatten the module graph to bare names.
JsEngine(JsEngineConfig(moduleScheme = "app")).use { engine ->
engine.registerModule("util", "export const url = import.meta.url; export function twice(n) { return n * 2; }")
engine.evaluateModule("import { twice, url } from 'util'; export default twice(21); export const from = url;", name = "main").use { ns ->
ns.get("default") // JsValue.Num(42.0)
ns.get("from") // JsValue.Str("app:util")
}
}evaluateModule returns the namespace as a JsRef, and the module can be imported by its name afterwards. A registered module is compiled at its first import and runs once; registered and evaluated names share one namespace, so claiming a name twice throws (names in angle brackets, like the default, are anonymous). Top-level await is supported: a module still pending when the call returns comes back as a JsRef with isPromise. Each evaluateModule call leaves the compiled module in the engine for its lifetime.
Names the table does not know can come from JsEngineConfig.moduleLoader instead: it is asked at the first import, static or dynamic import(), and answers with JsModuleSource.Text or JsModuleSource.Bytecode (compiled under exactly that name) or null for "unknown". A module it returns is cached and never asked again; null or an exception spends nothing, so the next import of that name asks again. Registered names are never asked. The loader runs synchronously on the engine thread inside the importing call and must not call the engine, so it is a cache lookup: fetch ahead of time, then let the script import().
val cache = mutableMapOf<String, ByteArray>() // filled by the host before the script imports
JsEngine(JsEngineConfig(moduleLoader = { name -> cache[name]?.let { JsModuleSource.Bytecode(it) } })).use { engine ->
engine.evaluate("import('pages/detail').then(m => m.title)") // JsValue.Str(...) once the loader served it
}JsBytecode.compile turns a script or module into engine bytecode without an engine. runBytecode runs a script any number of times; a module compiled under a real name runs once and claims that name like evaluateModule, and can instead be registered by that name so other modules import it. Bytecode is portable across architectures but bound to the engine build of the SDK that produced it (QuickJs.upstreamCommit): the header identifies that build and a mismatch is rejected with a clear JsException. That header is not an integrity or authenticity check and nothing else about the bytes is validated, so only load bytecode you built and stored yourself.
val page = JsBytecode.compile(pageSource, "pages/list", module = true, strip = JsBytecode.Strip.SOURCE) // at build time, or once on device
JsEngine().use { engine ->
engine.registerModule(JsBytecode.compile(coreSource, "@tiny-ui/core", module = true)) // returns "@tiny-ui/core"
(engine.runBytecode(page) as JsRef).use { ns -> ns.get("default", ObjectTransport.REF) }
engine.runBytecode(JsBytecode.compile("1 + 1")) // JsValue.Num(2.0)
}Strip.SOURCE drops the source text and keeps line numbers in stack traces; Strip.DEBUG drops all debug information.
Build pipelines can compile without Gradle or a JDK: the npm package qjsc-kmp ships the same compiler prebuilt for macOS (arm64, x64) and Linux (x64, arm64, statically linked). Use the version equal to this library's, so the bytecode names the engine the app runs.
npx qjsc-kmp@<version> -m -n pages/list --strip-source -o list.bin list.jsAsk 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 alive in the engine until then.
JsEngine().use { engine ->
val rules = engine.evaluate("({limit: 3, check(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() reports how many refs are still open (which is how the SDK's own tests prove nothing leaks) together with the engine's own memory accounting: bytes used, the configured limit, and object / string / atom / function counts.
@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. Json.encodeToJsValue / Json.decodeFromJsValue are the building blocks; JsValue.decode<T>(), JsEngine.evaluateAs<T>() and the typed registerFunction overloads (one to three arguments) are shortcuts.
JsRuntime serializes every access to one engine under a mutex, runs the work on a dispatcher of your choice (default: a single lane of Dispatchers.Default), 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()
}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 :library:macosArm64Test is the fastest full check; :library:testAndroidHostTest runs the same suite through the real JNI bridge on the host; :library:connectedAndroidDeviceTest runs it on a device or emulator../gradlew :library:nativeShimTest runs the C-level shim tests under AddressSanitizer; :library:buildHostTools builds the qjsc-kmp command line compiler (build/native/host-tools/bin) for build pipelines..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, and publishes the qjsc-kmp npm packages of the same version (built by qjsc.yml, which PRs run too).The engine is vendored under native/quickjs with git subtree, pinned to the commit recorded in native/UPSTREAM. QuickJs.upstreamCommit exposes that commit at runtime. Only the engine core is compiled (quickjs.c, libregexp.c, libunicode.c, cutils.c, dtoa.c); quickjs-libc is not linked, so console.log, print and performance.now come from the shim and everything else (timers, module loading) comes from the host.
MIT. QuickJS itself is MIT, copyright Fabrice Bellard and Charlie Gordon.
Kotlin Multiplatform bindings for QuickJS, the ES2025 JavaScript engine by Fabrice Bellard. Android + iOS, with macOS as a debug host.
English | 中文
Successor of mquickjs-kmp (archived); see docs/roadmap.md for what is done and what is next. Built for TinyUI but not tied to it.
| Target | Binding | Notes |
|---|---|---|
Android (minSdk 24) |
JNI |
.so bundled in the AAR (arm64-v8a, armeabi-v7a, x86_64) |
iOS (iosArm64, iosSimulatorArm64) |
cinterop | static library bundled in the klib, no CocoaPods / SPM |
macOS (macosArm64) |
cinterop | debug host for ASan, published as well |
commonMain.dependencies {
implementation("wang.harlon:quickjs-kmp:latest.release")
}JsEngine(JsEngineConfig(memoryLimit = 8L * 1024 * 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("const 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"
}JsValue.Num / Str / Bool / Null / Undefined, BigInt as decimal text in JsValue.BigInt, ArrayBuffer and typed arrays as a copy of their bytes in JsValue.Bytes (a Bytes handed to JS becomes an ArrayBuffer); other objects and arrays as JsValue.Json by default. JSON drops what JSON.stringify drops (functions, undefined properties, Map / Set contents); ask for ObjectTransport.REF when that matters.memoryLimit raises JsException with the engine's message and stack.Error with the Kotlin message.engine.interrupt() may be called from any thread and stops the running script with an uncatchable InternalError: interrupted.JsEngineConfig.maxStackSize (default 256 KB) must stay below the stack of the thread that runs the engine.JsRuntime (below) or serialize access yourself.Every outermost engine call drains the microtask queue before it returns, so then callbacks and await continuations run inside the same call. A Promise result is unwrapped: fulfilled gives its value, rejected throws JsException, still pending comes back as a JsRef whose isPromise is true; the Promise itself may settle during a later call, the flag does not change.
JsEngine(JsEngineConfig(onUnhandledRejection = { e -> println("lost: ${e.message}") })).use { engine ->
engine.evaluate("async function total(a, b) { await null; return a + b; }")
engine.evaluate("total(1, 2)") // JsValue.Num(3.0)
engine.evaluate("Promise.reject(new Error('x'))") // throws JsException("Error: x")
engine.evaluate("Promise.reject(new Error('y')); 0") // returns 0, handler receives "Error: y"
}Rejections nobody handled by the time the call returns go to onUnhandledRejection, or to logger as one line when no handler is set. Interrupting a call also discards the microtasks it left behind.
Modules are looked up in a name table by the exact specifier scripts use: register the sources the page may import, then evaluate the page module and read its namespace. There is no file-system loader and no relative-path resolution, so a bundler must flatten the module graph to bare names.
JsEngine(JsEngineConfig(moduleScheme = "app")).use { engine ->
engine.registerModule("util", "export const url = import.meta.url; export function twice(n) { return n * 2; }")
engine.evaluateModule("import { twice, url } from 'util'; export default twice(21); export const from = url;", name = "main").use { ns ->
ns.get("default") // JsValue.Num(42.0)
ns.get("from") // JsValue.Str("app:util")
}
}evaluateModule returns the namespace as a JsRef, and the module can be imported by its name afterwards. A registered module is compiled at its first import and runs once; registered and evaluated names share one namespace, so claiming a name twice throws (names in angle brackets, like the default, are anonymous). Top-level await is supported: a module still pending when the call returns comes back as a JsRef with isPromise. Each evaluateModule call leaves the compiled module in the engine for its lifetime.
Names the table does not know can come from JsEngineConfig.moduleLoader instead: it is asked at the first import, static or dynamic import(), and answers with JsModuleSource.Text or JsModuleSource.Bytecode (compiled under exactly that name) or null for "unknown". A module it returns is cached and never asked again; null or an exception spends nothing, so the next import of that name asks again. Registered names are never asked. The loader runs synchronously on the engine thread inside the importing call and must not call the engine, so it is a cache lookup: fetch ahead of time, then let the script import().
val cache = mutableMapOf<String, ByteArray>() // filled by the host before the script imports
JsEngine(JsEngineConfig(moduleLoader = { name -> cache[name]?.let { JsModuleSource.Bytecode(it) } })).use { engine ->
engine.evaluate("import('pages/detail').then(m => m.title)") // JsValue.Str(...) once the loader served it
}JsBytecode.compile turns a script or module into engine bytecode without an engine. runBytecode runs a script any number of times; a module compiled under a real name runs once and claims that name like evaluateModule, and can instead be registered by that name so other modules import it. Bytecode is portable across architectures but bound to the engine build of the SDK that produced it (QuickJs.upstreamCommit): the header identifies that build and a mismatch is rejected with a clear JsException. That header is not an integrity or authenticity check and nothing else about the bytes is validated, so only load bytecode you built and stored yourself.
val page = JsBytecode.compile(pageSource, "pages/list", module = true, strip = JsBytecode.Strip.SOURCE) // at build time, or once on device
JsEngine().use { engine ->
engine.registerModule(JsBytecode.compile(coreSource, "@tiny-ui/core", module = true)) // returns "@tiny-ui/core"
(engine.runBytecode(page) as JsRef).use { ns -> ns.get("default", ObjectTransport.REF) }
engine.runBytecode(JsBytecode.compile("1 + 1")) // JsValue.Num(2.0)
}Strip.SOURCE drops the source text and keeps line numbers in stack traces; Strip.DEBUG drops all debug information.
Build pipelines can compile without Gradle or a JDK: the npm package qjsc-kmp ships the same compiler prebuilt for macOS (arm64, x64) and Linux (x64, arm64, statically linked). Use the version equal to this library's, so the bytecode names the engine the app runs.
npx qjsc-kmp@<version> -m -n pages/list --strip-source -o list.bin list.jsAsk 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 alive in the engine until then.
JsEngine().use { engine ->
val rules = engine.evaluate("({limit: 3, check(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() reports how many refs are still open (which is how the SDK's own tests prove nothing leaks) together with the engine's own memory accounting: bytes used, the configured limit, and object / string / atom / function counts.
@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. Json.encodeToJsValue / Json.decodeFromJsValue are the building blocks; JsValue.decode<T>(), JsEngine.evaluateAs<T>() and the typed registerFunction overloads (one to three arguments) are shortcuts.
JsRuntime serializes every access to one engine under a mutex, runs the work on a dispatcher of your choice (default: a single lane of Dispatchers.Default), 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()
}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 :library:macosArm64Test is the fastest full check; :library:testAndroidHostTest runs the same suite through the real JNI bridge on the host; :library:connectedAndroidDeviceTest runs it on a device or emulator../gradlew :library:nativeShimTest runs the C-level shim tests under AddressSanitizer; :library:buildHostTools builds the qjsc-kmp command line compiler (build/native/host-tools/bin) for build pipelines..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, and publishes the qjsc-kmp npm packages of the same version (built by qjsc.yml, which PRs run too).The engine is vendored under native/quickjs with git subtree, pinned to the commit recorded in native/UPSTREAM. QuickJs.upstreamCommit exposes that commit at runtime. Only the engine core is compiled (quickjs.c, libregexp.c, libunicode.c, cutils.c, dtoa.c); quickjs-libc is not linked, so console.log, print and performance.now come from the shim and everything else (timers, module loading) comes from the host.
MIT. QuickJS itself is MIT, copyright Fabrice Bellard and Charlie Gordon.