Updates on Kotlin/Wasm and web‑workers
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 usingkotlinx-serialization
. Worker.postMessage
now accepts special typeJsAny
and not justkotlin.Any?
. Previously I spent quite some time figuring out why sendingString
s withpostMessage
from web worker did not work when documentation stated it should. The solution was to useasDynamic
extension function. Now that is not needed too:postMessage
ing aString
from JS source set just works. And in Wasm source setString
is not assignable toJsAny
and you need to call specialtoJsString
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 inheritscommonMain
). Solving code for the JVM target is just imported asdependencies { implementation(project(":solve")) }
insidejvmMain
sources set ofgui
project.jsMain
andwasmJsMain
containMain.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 insolve
project associated with tasks producing*.js
and*.wasm
files.generatedOutput
configuration ingui
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.