WASM Web Client
Overview
The web frontend is built with Compose Multiplatform targeting WASM (WebAssembly), sharing 100% of the business logic and Compose UI with the desktop, Android, and iOS apps from the shared module.
Unlike the previous Kotlin/JS React → TypeScript React migration plan (which required rewriting all UI code), the WASM approach lets us deploy the same Kotlin code to the browser with zero additional feature work.
How it Works
┌──────────────────────────────────────────┐
│ composeApp (Kotlin) │
│ ┌──────────┐ ┌──────────────────────┐ │
│ │ shared/ │ │ composeApp/src/ │ │
│ │ common │ │ wasmJsMain/ │ │
│ │ logic │◄─┤ - App.kt (entry) │ │
│ │ + UI │ │ - WebModules.kt │ │
│ └──────────┘ │ - PlatformConfig │ │
│ │ │ - DesktopConstants │ │
│ ┌────┴─────┐ └──────────────────────┘ │
│ │ wasmJs │ │
│ │ (actual) │ ┌─────────────────┐ │
│ └──────────┘ │ build/dist/ │ │
│ │ │ wasmJs/ │ │
│ ┌────┴─────────┐ │ productionExec/ │ │
│ │ webpack │──►│ - composeApp.js│ │
│ │ bundle │ │ - *.wasm │ │
│ └──────────────┘ │ - index.html │ │
│ └─────────────────┘ │
└──────────────────────────────────────────┘Build Variant System
All non-Android targets (wasmJs, desktop, iOS) use a unified BuildConfig generated by Gradle at compile time. The generated file lives at:
composeApp/build/generated/{sourceSet}/com/esteban/ruano/oter/utils/BuildConfig.ktGenerated BuildConfig.kt
package com.esteban.ruano.oter.utils
object BuildConfig {
const val IS_RELEASE: Boolean = true // or false
const val FLAVOR: String = "prod" // or "dev", "qa"
}Properties
-P property | Default | Values | Description |
|---|---|---|---|
-Poter.release | false | true, false | Controls whether the app uses release/debug URLs |
-Poter.flavor | "dev" | "dev", "qa", "prod" | Selects the API environment |
How Constants Are Resolved
Each platform has its own constants object that reads from BuildConfig:
| Platform | Constants File | Source Set |
|---|---|---|
| WASM | composeApp/.../wasmJsMain/.../DesktopConstants.kt | wasmJsMain |
| Desktop | composeApp/.../desktopMain/.../DesktopConstants.kt | desktopMain |
| iOS | composeApp/.../iosMain/.../IOSConstants.kt | iosMain |
| Android | androidApp/build.gradle.kts (buildConfigField per flavor) | android product flavors |
API Endpoints Per Flavor
| Flavor | BASE_URL | SOCKETS_HOST | SOCKETS_PORT |
|---|---|---|---|
| dev | http://localhost:8080/api/v1 | localhost | 8080 |
| qa | https://api-qa.estebanruano.com/api/v1 | api-qa.estebanruano.com | 443 |
| prod | https://api.estebanruano.com/api/v1 | api.estebanruano.com | 443 |
Running in Different Modes
WASM (Web)
# Dev (localhost server)
./gradlew :composeApp:wasmJsBrowserDevelopmentRun
# Prod (api.estebanruano.com)
./gradlew :composeApp:wasmJsBrowserDistribution -Poter.release=true -Poter.flavor=prodThe dev server runs on http://localhost:8081. Use F12 or Cmd+Option+I to open DevTools (right-click doesn’t work because Compose captures mouse events).
Desktop
# Dev (localhost server)
./gradlew :composeApp:run
# Prod (api.estebanruano.com)
./gradlew :composeApp:run -Poter.release=true -Poter.flavor=prodFor IntelliJ: create a Gradle run configuration:
- Run → Edit Configurations → + → Gradle
- Run:
:composeApp:run - Arguments:
-Potr.release=true -Potr.flavor=prod
Android
In Android Studio: View → Tool Windows → Build Variants → select prodDebug.
Android uses its own buildConfigField per product flavor (configured in androidApp/build.gradle.kts), so no -P properties are needed.
iOS
# Dev
./gradlew :composeApp:iosSimulatorArm64Framework
# Prod
./gradlew :composeApp:iosSimulatorArm64Framework -Poter.release=true -Poter.flavor=prodBundle Optimization
The WASM production build applies these optimizations:
| Optimization | Implementation | Effect |
|---|---|---|
| Webpack production mode | composeApp/build.gradle.kts | Minification, tree-shaking |
-Xwasm-opt compiler flag | Kotlin WASM compiler | WASM binary optimization |
| Long cache headers | CI workflow | Immutable cache for hashed assets |
Bundle Sizes
| Asset | Dev | Prod |
|---|---|---|
composeApp.wasm | ~46.8 MiB | ~9.0 MiB |
skiko.wasm | ~8.2 MiB | ~8.3 MiB |
composeApp.js | ~3.9 MiB | ~533 KiB |
| Total | ~55+ MiB | ~40 MiB |
CI/CD Workflows
| Workflow | File | Trigger |
|---|---|---|
| Publish WASM Web | .github/workflows/publish-wasm-release.yml | Manual |
| Publish Desktop | .github/workflows/publish-desktop-release.yml | Manual |
| Publish Android | .github/workflows/publish-android-release.yml | Manual |
| Publish Server | .github/workflows/publish-server-release.yml | Manual |
The WASM workflow:
- Builds with
-Poter.release=true -Poter.flavor=prod - Syncs to GCS bucket (
GCP_WASM_BUCKET) - Hashed assets (
.wasm,.js) getmax-age=31536000,immutablecache index.htmlgetsmax-age=0,must-revalidate
Project Structure
Key files for the WASM build:
composeApp/
├── build.gradle.kts # WASM target + BuildConfig task + webpack config
├── src/
│ ├── commonMain/kotlin/.../utils/
│ │ └── PlatformConfig.kt # expect declaration
│ ├── wasmJsMain/kotlin/.../
│ │ ├── App.kt # Entry point (ComposeViewport)
│ │ ├── di/WebModules.kt # Koin DI module
│ │ ├── ui/viewmodels/WebAppViewModel.kt
│ │ ├── utils/DesktopConstants.kt # Reads from BuildConfig
│ │ ├── utils/PlatformConfig.wasm.kt
│ │ └── resources/
│ │ ├── index.html
│ │ └── styles.css
│ ├── desktopMain/kotlin/.../utils/
│ │ └── DesktopConstants.kt # Reads from BuildConfig
│ └── iosMain/kotlin/.../utils/
│ └── IOSConstants.kt # Reads from BuildConfig
shared/
└── src/
├── commonMain/ # Shared business logic + UI
├── wasmJsMain/ # WASM actual implementations
├── nonJsMain/ # JVM/Android/iOS-only (DataStore)
├── androidMain/
├── iosMain/
└── jvmMain/Known Limitations
| Issue | Status | Workaround |
|---|---|---|
| Right-click doesn’t show context menu | Inherent | Use F12/Cmd+Option+I for DevTools |
| Browser blocks CORS if server not running | Environment | Start server first, or use localhost |
| CLIENT_ERROR_UNREADABLE on login | Fixed | ValidateResponse no longer throws for 401 on public auth paths |
| MDCContext ClassNotFoundException | Workaround | Run server via ./gradlew :server:run (not IntelliJ run button) |