Skip to content

myTunes

CI CodeQL GitHub Pages Docker Image Size Java 25 Spring Boot 4.1.1 WebAssembly Zero JavaScript License PRs Welcome

Live demo: https://tunes.patbaumgartner.com/ — deployed by CI from the exact image the browser tests just verified. (GitHub Pages serves no custom headers, so the CSP and caching policy documented below apply to the nginx container, which remains the reference deployment.)

A radio player inspired by DevTunes FM, built to answer one question:

Can a complete Spring Boot application — the framework, the domain, and the user interface — be compiled from Java to WebAssembly and run entirely inside a browser, with no server?

Yes. Spring Boot 4.1.1 and Spring Modulith 2.1.0 start in the browser tab in under 200 ms with 22 beans, build the interface against the live DOM, play audio, and persist preferences. The container that serves it holds no JVM and no jar.

[mytunes] Spring Boot 4.1.1 started in the browser in 137ms with 22 beans
[mytunes] interface ready

Screenshots

Every pixel below is rendered by Java: the interface is built against the live DOM from inside the WebAssembly module, and the animated landscapes are generated SVG scenes with a declarative SMIL day-night cycle.

The player at dawn: translucent controls floating over an animated landscape

Station menu Night scene Mobile
Station menu open, channels grouped into categories The night-pines background under a starfield Mobile portrait layout at 390×844

Contents


What this is

myTunes is a single-page radio player: pick a station, press play, change the background, adjust the volume. It looks and behaves like DevTunes FM. What makes it unusual is that all of it is Java. The station catalogue, the player state machine, the preference store, the DOM construction, the event handling and the audio control are Java classes compiled ahead of time into a WebAssembly module by GraalVM Web Image.

There is no backend. Nothing is rendered on a server, and no API is called.

Why Java to WebAssembly

The interesting part is not "compile Java to Wasm" — several projects do that. It is that WebAssembly has no access to the DOM, so a browser UI written in Java needs an interop layer, and the usual answer is to hand-write JavaScript for it. This project set out to avoid that, and to find out whether a framework as reflection-heavy and lifecycle-heavy as Spring Boot survives the closed-world, ahead-of-time, single-threaded environment a Wasm module runs in.

Six specific adaptations were required to get there, each documented at its call site and summarised under GraalVM Web Image: status and limits.

Architecture

Docker (nginx, unprivileged)
  └── static assets only, no JVM, no jar
        ├── index.html          bootstrap shell, no behaviour
        ├── styles.css          presentation
        ├── backgrounds/, icons/, audio/
        ├── mytunes.js          GraalVM-generated loader  (the only script the page loads)
        └── mytunes.js.wasm     the entire application
                 ├── Spring Boot 4.1.1
                 ├── Spring Modulith 2.1.0
                 ├── domain: stations, player, persistence, themes
                 └── browser: DOM, audio, localStorage, Media Session, Picture-in-Picture

The browser downloads the module, the generated loader instantiates it and calls main(), and main() runs SpringApplication.run(...) inside the tab.

Modules

Spring Modulith boundaries are declared in each package-info.java and verified by ModularityTests. The rule that matters most is that only platform may touch the browser, which is what keeps the domain unit-testable on a plain JVM even though the product only ever runs as WebAssembly.

Module Responsibility May depend on
stations Station catalogue and stream URLs
themes Background artwork and accent colours
persistence Preferences, versioned store, storage abstraction
player Player state machine. No browser APIs at all stations, persistence
platform Every JavaScript interop call in the project persistence
ui Builds and renders the interface player, stations, themes, persistence, platform

The zero-JavaScript rule

The repository contains no .js, .mjs, .cjs or TypeScript source file, no JavaScript DOM bridge and no JavaScript audio adapter. The page loads exactly one script: the loader GraalVM generates.

There is one exception, and it is deliberately visible. GraalVM Web Image offers no way to obtain a browser global from Java — its interop API has no global accessor, and @JS.Import imports a JavaScript class rather than an instance. This was verified against Oracle GraalVM 25.0.4, GraalVM CE 25.2.4 and the Early Access build jdk-25i3-25.0.4.1-ea.02. Reaching the browser therefore needs exactly two one-expression declarations, both in BrowserWindow:

@JS("return document;") static native JSObject document();
@JS("return window;")   static native JSObject window();

Everything else — creating elements, registering listeners, controlling audio, reading localStorage, constructing MediaMetadata via Reflect.construct, driving the Picture-in-Picture window — is plain Java through get, set and call.

NoHandwrittenJavaScriptTests enforces this: it fails if any JavaScript or TypeScript source appears anywhere in the repository, if any class other than BrowserWindow declares @JS, if that surface grows beyond two expressions, or if index.html gains an inline script or an event attribute.

Prerequisites

Oracle GraalVM 25.0.4 Required, not a preference. It is the only JDK that ships the Wasm backend (lib/svm/tools/svm-wasm) and a matching browser interop module. GraalVM CE carries a diverged copy of that API — CE 25.2.4 exposes ThrownFromJavaScript where this build needs JSError — so on CE even ./mvnw test fails to compile. sdk install java 25.0.4-graal
Binaryen 119+ Web Image assembles output with wasm-as, which must be on PATH
Docker Only for the container build

Build from a clean clone

export JAVA_HOME=/path/to/graalvm-jdk-25.0.4
export PATH="$JAVA_HOME/bin:/path/to/binaryen/bin:$PATH"

# One-time bootstrap. Repackages GraalVM's browser interop module as a Maven artifact so the
# whole build and Spring Boot's AOT step share one compiler configuration.
./tools/install-webimage-api.sh

./mvnw -B -Pnative native:compile      # produces target/mytunes.js and target/mytunes.js.wasm

# Assemble the site
mkdir -p target/site && cp -r src/main/web/. target/site/ \
  && cp target/mytunes.js target/mytunes.js.wasm target/site/

tools/generate-artwork.py and tools/generate-audio.py regenerate the backgrounds, icons and the self-hosted station. Their output is committed, so a normal build does not need Python.

Docker

The image needs no local JVM or GraalVM. CI publishes every tested main build to Docker Hub as patbaumgartner/mytunes (latest plus a sha- tag per commit).

docker run -d --name mytunes -p 8099:8080 patbaumgartner/mytunes:latest
# …or build it yourself
docker build -t mytunes:latest .
docker run -d --name mytunes -p 8099:8080 mytunes:latest
# http://localhost:8099

Readiness is GET /healthz (also wired as a HEALTHCHECK). The runtime stage is unprivileged nginx containing only the bootstrap page, CSS, artwork, the generated loader and the Wasm module. You can check the central claim yourself:

docker run --rm --entrypoint sh mytunes:latest -c "find / -name '*.jar' -o -name java -type f"   # empty

Generated Wasm is not committed. It is produced during the build.

Tests

The suite is layered. Everything that can be verified without a browser is, and everything that cannot is verified in one.

./mvnw -B test        # the JVM suite
Layer Covers
Unit Player state machine, versioned persistence, station and background catalogues
Spring context PlayerModuleIntegrationTests refreshes a real context for the player module and its declared dependencies with @ApplicationModuleTest, proving the module boundaries are sufficient off-browser
Modularity ModularityTests — Spring Modulith detectViolations()
Architecture ArchitectureTests — Taikai conventions over the authored classes
Constraint NoHandwrittenJavaScriptTests — the zero-JavaScript rule

Browser tests are authoritative, because the application only ever executes in a browser. They are excluded from the default run because they need a built image, and skip themselves if it is absent.

# Local build
./mvnw -B test -Dsurefire.excludes= -Dtest='MyTunesBrowserTests,MiniPlayerBrowserTests' \
  -DfailIfNoTests=false

# …or against the running container
./mvnw -B test -Dsurefire.excludes= -Dtest='MyTunesBrowserTests,MiniPlayerBrowserTests' \
  -DfailIfNoTests=false -Dmytunes.baseUrl=http://127.0.0.1:8099

# Container smoke tests: content types, cache headers, and that the image holds no JVM or jar
./mvnw -B test -Dsurefire.excludes= -Dtest=DockerSmokeTests -DfailIfNoTests=false \
  -Dmytunes.baseUrl=http://127.0.0.1:8099 -Dmytunes.dockerImage=mytunes:latest

They assert real behaviour, not intent: that currentTime actually advances, that the volume element follows the slider, that state survives a reload, that the page loads exactly one script, and that docker run ... find / -name '*.jar' -o -name java returns nothing. Screenshots at four breakpoints and the console logs land in target/diagnostics/ (uploaded as a CI artifact on every run).

Module size and startup timing are recorded to target/diagnostics/console/wasm-diagnostics.log on every browser run:

springStartup=[mytunes] Spring Boot 4.1.1 started in the browser in 137ms with 22 beans
mytunes.js.wasm=16051547 bytes
mytunes.js=96732 bytes

Coverage note, stated plainly: JaCoCo instruments JVM bytecode, and the platform, ui and wasm classes execute only as WebAssembly where no Java agent exists, so they are excluded from instrumentation and verified in a real browser instead. The domain they delegate to (stations, player, persistence, themes) is fully unit tested on the JVM.

Stored data

localStorage, through the Java interop layer. Nothing secret is stored; these are display and playback preferences.

Key Meaning
mytunes.schema Storage format version, currently 1
mytunes.station Selected station id
mytunes.background Selected background id
mytunes.volume 0.01.0
mytunes.muted true / false

A record written by a newer schema is discarded rather than half-read, so a future format degrades to defaults instead of restoring corrupt state. Unparseable values fall back individually. Storage failures (private mode, exhausted quota) are contained: losing a volume preference must never stop the radio.

Stations and streams

The catalogue holds eight categories, each with several channels (30 in total), grouped in the station menu.

Category Channels Source
Ambient myTunes Signal (default), Drone Zone, Deep Space One, Synphaera generated · SomaFM
Chill myTunes Lo-Fi, Groove Salad, Lush, Fluid, Chillsynth FM, Radio Paradise Mellow generated · SomaFM · Nightride FM · Radio Paradise
Beats myTunes Beats, Beat Blender, Radio Paradise Global generated · SomaFM · Radio Paradise
Bass myTunes Sub Signal, Dub Step Beyond, EBSM generated · SomaFM · Nightride FM
Electronic myTunes Pulse, Space Station, cliqhop idm, Spacesynth FM generated · SomaFM · Nightride FM
Trance myTunes Drift, The Trip generated · SomaFM
Wave myTunes Nightdrive, Underground 80s, Vaporwaves, Nightride FM, Datawave FM generated · SomaFM · Nightride FM
Hacker myTunes Terminal, DEF CON Radio, Darksynth FM generated · SomaFM · Nightride FM

Every category leads with a channel generated by tools/generate-audio.py and served from this repository — always available, same origin, no licensing question — and StationCatalogueTests enforces exactly that. Generated channels loop seamlessly, since a finite file stands in for a continuous station.

The third-party channels come from three providers — SomaFM, Nightride FM and Radio Paradise — all verified to play in a real browser: an Audio element reaches canplay on every committed stream.

tools/generate-stations.py keeps this honest: it re-verifies every committed third-party stream the way a browser would use it (--verify), and proposes new CORS-verified candidates per category from the open, key-free Radio Browser directory as ready-to-paste catalogue entries. Nothing lands automatically — a human reviews the provider's terms and commits the entry, and StationCatalogueTests enforces the invariants.

SoundCloud stays out deliberately: its API requires a registered client_id, which would ship in plain sight inside the WebAssembly bundle, and its terms only sanction playback through SoundCloud's own widget. StationCatalogueTests fails the build if any stream URL ever gains credential-bearing parameters (client_id, signed-CDN Policy/Signature/Key-Pair-Id).

Caveats, stated plainly:

  • Stream availability changes independently of myTunes. A URL that works today may not tomorrow.
  • Before any production or commercial use, replace these with streams you own or are licensed to use, and honour each provider's terms.
  • No API key, token, cookie or credential is committed. secret-scan passes.

Artwork

The five backgrounds and nine icons are original flat-design SVG generated by tools/generate-artwork.py, and the eight myTunes channels are loops synthesised by tools/generate-audio.py — one per category, each a distinct deterministic arrangement. They are covered by this repository's own licence. Each background is a small animated scene (declarative SMIL inside the image, so the zero-JavaScript rule is untouched): the sun crosses the sky in about a minute and hands over to a cratered moon travelling the same way, the scene darkens towards midnight under a starfield, clouds drift, birds glide past, ripples play on the water, reeds sway, fireflies come out around midnight, and a pair of rabbits watches from the foreground knoll — one of them twitching an ear now and then.

DevTunes FM serves wallpapers sourced from wallhaven. Their redistribution terms are not established, so none are reused, and BackgroundCatalogueTests asserts that all artwork is served from this repository.

Browser support

Verified in Chromium via Playwright. Requires WebAssembly with GC and exception handling, so a current Chromium, Firefox or Safari. Node 22–24 needs --experimental-wasm-exnref.

The module is ~16 MB uncompressed: release builds omit -g, compile with -Os, keep snakeyaml and the Modulith annotation processor off the runtime classpath, and the container build runs a wasm-opt -Oz pass (down from ~30 MB with debug annotations; rebuild with -Pwasm-debug when hunting a browser-side crash). On the wire it is ~6.8 MB: the image build precompresses every compressible asset at gzip -9 and nginx serves the .gz bytes directly (gzip_static), marked immutable. GitHub Pages applies its own gzip (~6.9 MB). Startup from navigation to a rendered interface is under about a second locally.

Mobile

Verified at 390×844 and 360×640. The transport and volume rows stack to full width, touch targets stay at the 44 px minimum, and env(safe-area-inset-bottom) is respected. Zero console errors at every breakpoint.

Browser autoplay policy is respected rather than worked around: the first audible playback needs a real tap. iOS does not expose volume to script, so the slider is inert there while mute still works — a platform rule, not a bug.

Media Session and mini player

Both are feature-detected and degrade to nothing where unsupported.

Media Session publishes title, station, album and artwork, and handles play, pause, next and previous, which is what drives lock-screen and keyboard media keys.

Document Picture-in-Picture opens a floating always-on-top mini player carrying the full transport — previous/play/next, mute and volume — wired to the same player state as the main page. requestWindow() returns a second Window, which reaches Java as an ordinary JSObject, so the mini player is built with the same Java DOM vocabulary and needs no extra JavaScript.

One limit stated honestly: the mini player stays visible while the tab is hidden or you work in other applications, but it cannot outlive the browser process. No web application can keep a window open after the browser that owns it is closed. That would require an installed native application, and no amount of browser API makes it possible.

GraalVM Web Image: status and limits

Web Image is experimental. Limits that shaped this code, all found by building and running:

  1. Single-threaded. No Thread, no ScheduledExecutorService, no @Scheduled. Spring Boot's shutdown hook is disabled and spring-modulith-moments is excluded, because its cron-scheduled passage-of-time events force a worker thread.
  2. StackWalker always throws. SpringApplication.deduceMainApplicationClass() walks the stack, so it is substituted to read primarySources instead. log4j's StackLocator does too, so spring-boot-starter-logging is excluded.
  3. java.io.Console cannot link. System.console() is substituted to return null, which is the specified value when there is no terminal.
  4. @JS.Import, @JS.Export and JSObject subclasses are unimplemented in shipping releases.
  5. Java stack traces are empty inside Wasm. Diagnosis needs -g plus a raised Error.stackTraceLimit to read named Wasm symbols.
  6. The build requires Oracle GraalVM, because the interop API is a JDK module.

Known issues

  • Live track titles are not shown. The original reads them from its own playlist service. myTunes has no server, and ICY stream metadata is not exposed to browser JavaScript, so per-track titles are not obtainable. The station name and genre are shown instead; PlayerState.nowPlaying is in place for when a source exists.

Contributing

Contributions are welcome — read CONTRIBUTING.md for the toolchain, the build commands CI actually runs, and the one rule that is different here (no hand-written JavaScript). Security reports go through SECURITY.md, never public issues.

Licence

Apache License 2.0. That covers the code and the generated artwork and audio, which is the point of generating them rather than reusing someone else's: the whole bundle can be redistributed under one clear licence.

Third-party streams are not covered and remain subject to their providers' terms.

About

DevTunes FM inspired radio player — Spring Boot compiled to WebAssembly by GraalVM Web Image, running entirely in the browser. No server, no hand-written JavaScript.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

36 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages