Tidbits About Compose Web

Compose on the web has always fascinated me. It’s always been really cool to take a working application on Android and have it run with minimal changes on various other platforms, including the web. While I’ve written about it in the past and show cased it in talks, it’s always been a toy of sorts for me.

This changed one day when I wanted to write a tool for visualizing some data at work. I decided to write a desktop app, and, I opted to use Kotlin and Compse, guessing it’d be the fastest way to accomplish what I want. After making it work, I started thinking of how I could share this tool with my colleagues. Sharing jars or instructions for building wasn’t as interesting, so what about just sharing a url? This made the utility of Compose for web much more apparent to me.

In this post, I want to share a few odds and ends and tidbits that I learned while working on various Compose for Web projects.

Development is Fast

Generally speaking, Compose code written for Android will work on the web and in WebAssembly with minimal changes. There are still some differences, and often times, code that will compile on Android and JS (for example) won’t compile cleanly on WebAssembly. One work around for this is to use expect/actual to provide empty or no-op implementations for the offending platform and keep the real implementations where it compiles.

The development cycle is very fast, due to auto-reload whenever a change is made. Make sure to pass the -t flag to jsRun or wasmJsRun to ensure auto rebuilds and reloads of the page whenever changes are made. Without it, changes to the code seem to reload the page, but without reflecting any of the changes.

Deploying to Production is Easy

In a KMP project with web support, building the jsBrowserDistribution task (or wasmJsDistributionTask for WebAssembly) will output a folder named productionExecutable under the build/dist/js or build/dist/wasm folder. These tasks will run optimizations (dead code removal, minification, etc). You can simply take the contents of that directory and deploy it to a server somewhere and it should just work. This makes for some compelling demos. As I learned from one of those repos, it’s quite easy to wire this up to GitHub Pages on every merge.

One trick I use to validate this locally is going to the productionExecutable directory, and running a simple HTTP server with Python:

python3 -m http.server

This allows me to test the production version locally, since using file:// urls doesn’t work due to security restrictions in the browser.

Kotlin/JS on the Web is Client Side

When declaring support for a web project, we add the type of web projects supported - either browser or nodejs. Code targeting browser runs in the browser, which means it runs on the client’s machine, whereas code targeting nodejs can run on the server.

This all seems obvious, so why mention it? Unfortunately, sometimes, developers forget this point. I saw an open source project put in some clever amount of work to hide an api key, wiring it through GitHub secrets to an environment variable in GitHub Actions, and then injecting it into the code at build time using BuildKonfig. The problem is that the code runs in the browser, so the api key is exposed to anyone who cared to look at the networking logs in the developer console.

As a workaround, the project could have a server side component that does the actual logic of talking to the third party API, and the web code can talk to this component. This keeps the API key from being exposed to the client, and allows the developer to control access to the API (add rate limiting, caching, etc).

Cross-Origin Resource Sharing (CORS)

Thanks to coil3, showing images from a URL is as easy as a few lines of code in Compose:


@Composable
fun WebImage(url: String, contentDescription: String, modifier: Modifier) {
    AsyncImage(
        model = ImageRequest.Builder(LocalPlatformContext.current)
            .data(url)
            .build(),
        contentDescription = contentDescription,
        modifier = modifier
    )
}

This works well and looks great, but when you try it on web, you often find yourself running into images that don’t load. Looking at the logs, you see things like:

Access to fetch at '<url>' from origin 'http://localhost:8080' has been blocked
by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Today, browsers have security checks that disallow loading resources from other domains, unless those domains explicitly allow it. This type of check helps prevent CSRF and various other attacks. This effectively means that the same URL an be read using curl or OkHttpClient, but not from the web due to these web browser security checks.

So what can you do?

  1. If you own the server, you can add the Access-Control-Allow-Origin header to the response. This tells the browser that it’s okay to load the resource from this particular domain.
  2. If you only want to test locally, you can disable CORS checks in your browser.
  3. Another option is to configure a proxy server that you own and control to fetch the resource and serve it back to the client. The server can easily get the resources (since there are no CORS checks outside of the browser), and can set the proper headers when serving the resource back to the client. There are various open source solutions that also do this.

Kotlin/Wasm and Kotlin/JS

I wrote before about using Compose/JS without WebAssembly. That having been said, the code for both is often very easy to share, since it’s almost identical. One of the exciting promises of WebAssembly today is the massive performance benefits in comparison to vanilla JavaScript. While today, wasm is supported out of the box (without special configurations) on Chrome and Firefox, it still doesn’t work on Safari just yet.

One thing I learned is that even Compose/JS uses WebAssembly under the hood for Skia drawing via Skiko. As of this writing, the size of the WebAssembly artifacts for this repository is around 28mb, whereas the size of the non-WebAssembly version is 13mb (uncompressed). Close to 8.5mb of these come from Skiko in both versions (with matching md5 sums of these files).

As of this writing, Ktor has a 3.0.0-wasm1 version that supports WebAssembly in its EAP repository. This version worked for me for both vanilla JavaScript and WebAssembly in debug, though release versions of JavaScript code failed to run. I worked around this for now by only using the 3.0.0-wasm1 version for wasm targets, and using the stable 2.3.8 version for JavaScript targets.