Skip to Content
InfraLegacy: WASM web client

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.kt

Generated 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 propertyDefaultValuesDescription
-Poter.releasefalsetrue, falseControls 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:

PlatformConstants FileSource Set
WASMcomposeApp/.../wasmJsMain/.../DesktopConstants.ktwasmJsMain
DesktopcomposeApp/.../desktopMain/.../DesktopConstants.ktdesktopMain
iOScomposeApp/.../iosMain/.../IOSConstants.ktiosMain
AndroidandroidApp/build.gradle.kts (buildConfigField per flavor)android product flavors

API Endpoints Per Flavor

FlavorBASE_URLSOCKETS_HOSTSOCKETS_PORT
devhttp://localhost:8080/api/v1localhost8080
qahttps://api-qa.estebanruano.com/api/v1api-qa.estebanruano.com443
prodhttps://api.estebanruano.com/api/v1api.estebanruano.com443

Running in Different Modes

WASM (Web)

# Dev (localhost server) ./gradlew :composeApp:wasmJsBrowserDevelopmentRun # Prod (api.estebanruano.com) ./gradlew :composeApp:wasmJsBrowserDistribution -Poter.release=true -Poter.flavor=prod

The 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=prod

For IntelliJ: create a Gradle run configuration:

  1. Run → Edit Configurations → + → Gradle
  2. Run: :composeApp:run
  3. 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=prod

Bundle Optimization

The WASM production build applies these optimizations:

OptimizationImplementationEffect
Webpack production modecomposeApp/build.gradle.ktsMinification, tree-shaking
-Xwasm-opt compiler flagKotlin WASM compilerWASM binary optimization
Long cache headersCI workflowImmutable cache for hashed assets

Bundle Sizes

AssetDevProd
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

WorkflowFileTrigger
Publish WASM Web.github/workflows/publish-wasm-release.ymlManual
Publish Desktop.github/workflows/publish-desktop-release.ymlManual
Publish Android.github/workflows/publish-android-release.ymlManual
Publish Server.github/workflows/publish-server-release.ymlManual

The WASM workflow:

  1. Builds with -Poter.release=true -Poter.flavor=prod
  2. Syncs to GCS bucket (GCP_WASM_BUCKET)
  3. Hashed assets (.wasm, .js) get max-age=31536000,immutable cache
  4. index.html gets max-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

IssueStatusWorkaround
Right-click doesn’t show context menuInherentUse F12/Cmd+Option+I for DevTools
Browser blocks CORS if server not runningEnvironmentStart server first, or use localhost
CLIENT_ERROR_UNREADABLE on loginFixedValidateResponse no longer throws for 401 on public auth paths
MDCContext ClassNotFoundExceptionWorkaroundRun server via ./gradlew :server:run (not IntelliJ run button)