Updates on Kotlin⁠/⁠Wasm and web‑workers

5 Oct 2024 • 4 minutes read

In the last article I implemented 2048 game AI using Kotlin Multiplatform and Korge. It’s been more than a year since then and time has come to make everything up-to-date.

KotlinSection titled Kotlin

Since the last year, Kotlin 2.0.20 has been released with a new compiler and new JS/Wasm IR compiler backend. And despite that Kotlin/Wasm is still in Alpha, it is now more stable and a lot of the popular libraries have added Wasm support since then: kotlinx-coroutines, kotlinx-serialization, kotlinx-atomicfu and Korge itself. So most of the hacks outlined in the previous article are now not necessary:

  • Kotlin/Wasm now just works ™ inside web workers and webpack string-replace-loader chicanery is not needed anymore.
  • Deserialization “by hand” is not needed because kotlinx-serialization now supports Wasm. But I still left manual serialization because bundle size grows a lot when using kotlinx-serialization.
  • Worker.postMessage now accepts special type JsAny and not just kotlin.Any?. Previously I spent quite some time figuring out why sending Strings with postMessage from web worker did not work when documentation stated it should. The solution was to use asDynamic extension function. Now that is not needed too: postMessageing a String from JS source set just works. And in Wasm source set String is not assignable to JsAny and you need to call special toJsString function.
  • Not related to Kotlin itself, but Chromium-based browsers and Firefox now support Wasm Garbage Collection by default (which is needed for the Kotlin/Wasm to work). WebKit/Safari are also close to delivering the feature.

Declaring several similar targetsSection titled Declaring%20several%20similar%20targets

Kotlin now discourages usage of similar targets in a single Gradle project. Previously I had separate targets for web workers and main JS code like so:

target {
  js("main")
  js("jsWorker")
}

Kotlin team is deprecating this feature in future releases and suggests using Gradle subprojects. After refactoring the end result turned out even better than before!

I extracted several Gradle subprojects for different purposes:

include("gui") // for all Korge-related UI code
include("common") // for the shared interfaces and classes
include("solve") // for the AI solving
include("benchmark") //for `kotlinx-benchnmark` tests

I moved all the solving AI code to solve project commonMain source set. Platform specific source sets are used differently:

  • jvmMain is empty (it entirely inherits commonMain). Solving code for the JVM target is just imported as dependencies { implementation(project(":solve")) } inside jvmMain sources set of gui project.
  • jsMain and wasmJsMain contain Main.kt file and are designed to run inside a web worker.

In gui project I spawn web workers with Worker("./js.js") or Worker("./wasmJs.js"). But how to get those js.js and wasmJs.js (and *.wasm) files copied to the folder where gui.js bundle and index.html are produced?

Copying output of the solve project inside gui project resourcesSection titled Copying%20output%20of%20the%20solve%20project%20inside%20gui%20project%20resources

The approach I used in the previous article was very sketchy: I used copy-webpack-plugin to copy js and wasm files between folders inside build directory.

Now as every project has its own build directory I opted for the proper Gradle-way solution.

In solve project we create a new Gradle configuration distribution and assign two Kotlin Multiplatofrm plugin tasks jsBrowserDistribution and wasmJsBrowserDistribution that produce dist/*.js and dist/*.wasm to that configuration like so:

val distribution: NamedDomainObjectProvider<Configuration> by configurations.registering {
    isCanBeConsumed = true
    isCanBeResolved = false
}
artifacts {
    add(distribution.name, tasks.named("jsBrowserDistribution"))
    add(distribution.name, tasks.named("wasmJsBrowserDistribution"))
}

In gui project we create another new configuration generatedOutput and add distribution configuration from solve project as its dependency:

val generatedOutput: Configuration by configurations.creating {
    isCanBeConsumed = false
    isCanBeResolved = true
}

dependencies.add(generatedOutput.name, project(":solve"), {
    targetConfiguration = "distribution"
})

With this setup we have two Gradle configurations:

  • distribution configuration in solve project associated with tasks producing *.js and *.wasm files.
  • generatedOutput configuration in gui project that depends on previous configuration (from another project!)

So now we ensured that artifacts from distrubution configuration will also appear in generatedOutput configuration and execution order is correct.

The last step is to copy files in the build folder of the gui project and make that new folder a resource dir for the jsMain source set. We create new Copy task that copies relevant files of the output of generatedOutput configuration into a new folder generatedOutput:

val copyGeneratedOutput: TaskProvider<Copy> by tasks.registering(Copy::class) {
    from(generatedOutput)
    into(project.layout.buildDirectory.dir("generatedOutput"))
    include { elem ->
        listOf(".js", ".js.map", ".wasm")
            .any(elem.name::endsWith)
    }
}

And then register this new generatedOutput folder as a resource directory for the jsMain source set:

  jsMain {
      resources.srcDir(copyGeneratedOutput)
  }

Now spawning web workers inside gui project jsMain source set with Worker("./js.js") or Worker("./wasmJs") works as ./js.js and wasmJs.js files are resources and are copied in the same output directory with the main gui.js bundle.