Embedding the wasm JVM

Integration guide

How to run OpenJDK 21 inside your own web app: load a runtime bundle, boot it, feed it a program, and (optionally) wire a canvas for graphics and input. Everything runs client-side — no server.

1. Cross-origin isolation (required)

The JVM uses wasm threads, which need SharedArrayBuffer, which needs the page to be cross-origin isolated. That normally requires two response headers:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

If you control the server, send those. If you don't (e.g. GitHub Pages), drop in coi-serviceworker.js — a service worker that adds them client-side — as the first script in your page:

<script src="coi-serviceworker.js"></script>

Check self.crossOriginIsolated === true before booting.

2. Pick a runtime bundle

Each bundle is a <name>.js loader plus a .wasm, a .worker.js, and a JDK data image. It exposes one global factory.

BundleFactoryCapabilities
jvm-basecreateJVMplain Java + in-VM javac
jvmawtcreateJVMSwing / AWT / Java2D → canvas
jvmglcreateJVMGLAWT + OpenGL→WebGL

Ship the JDK data image gzipped and decompress it in the browser, then hand the bytes to the runtime through Module.getPreloadedPackage:

// fetch + gunzip the JDK image once
const gz = await fetch('jvm/jvm-base.data.gz');
const data = await new Response(
    gz.body.pipeThrough(new DecompressionStream('gzip'))).arrayBuffer();

3. Boot and run a program

The factory takes an Emscripten module config. arguments[0] is the main class; write control files into /work in preRun; capture output with print/printErr.

<script src="jvm/jvm-base.js"></script>
<script>
createJVM({
  arguments: ['Hello'],                       // main class on the /app classpath
  getPreloadedPackage: () => data,            // the gunzipped image from step 2
  preRun: [ M => { M.FS.mkdir('/work'); } ],
  print:    s => console.log(s),
  printErr: s => console.error(s),
});
</script>

The launcher reads these optional /work files before starting the VM:

FileEffect
/work/classpathone line → -Djava.class.path (default /app)
/work/addmodsone line → --add-modules
/work/argsN lines → program args
/work/vmoptsN lines → extra JVM options

4. Compile from source (in-VM javac)

The base bundle bakes jdk.compiler plus two driver classes. Write your source to /work/src.java and run a driver:

// warm REPL: boot ReplServer once…
createJVM({ arguments:['ReplServer'], getPreloadedPackage:()=>data,
  preRun:[M=>{ mod=M; M.FS.mkdir('/work'); M.FS.writeFile('/work/req','0'); }],
  print:onOut, printErr:onErr });

// …then for each run, write the source and bump the request:
let seq = 0;
function run(source){
  mod.FS.writeFile('/work/src.java', source);
  mod.FS.writeFile('/work/req', String(++seq));
  // poll /work/resp until it equals String(seq) → run finished
}

5. Graphics and input

AWT/GL apps publish frames and read input over a small /work wire protocol. Rather than implement it, use the framework's reusable Screen module — it presents frames to a canvas and forwards mouse/keyboard back to the app:

import { Screen } from './jvm/screen.js';

const screen = new Screen(canvas, {
  present: '2d',        // blit RGBA frames (or 'webgl')
  input:   true,        // forward pointer + keyboard to the app
  mousemove: true,
  getModule: () => mod, // {{ FS, HEAP32, HEAPU8 }} accessor
}).start();

Screen maps canvas coordinates through any CSS scaling, so you can display a small framebuffer at any size. Call screen.stop() to detach.

6. Networking (real TCP via a relay)

Browsers can't open raw TCP sockets, so a java.net.Socket / HttpURLConnection is tunneled over a single WebSocket to a small native relay that performs the actual TCP + DNS. Verified end-to-end: an HttpURLConnection GET of http://example.com/ returns a real HTTP 200 and body.

Pieces. A networking-enabled runtime is a distinct bundle (a socket-proxy build, e.g. jvm-*-net) linked with -sPROXY_POSIX_SOCKETS plus a patched wsps.c that routes the JDK's read/write/close/poll over the bridge. The relay is framework/net/tcp-relay.cjs (Node + the ws library, speaking Emscripten's websocket_to_posix_proxy protocol over node net/dns).

Run it.

# 1. start the relay (defaults to port 8114)
node framework/net/tcp-relay.cjs 8114

# 2. tell the VM which relay to use, before boot, by writing /work/bridge
createJVM({
  arguments: ['Main'],
  getPreloadedPackage: () => data,
  preRun: [ M => {
    M.FS.mkdir('/work');
    M.FS.writeFile('/work/bridge', 'ws://localhost:8114/');  // the relay's WS URL
  } ],
});

The launcher connects the bridge and waits for the WebSocket to open before starting Java, so sockets are live during VM init. The socket-proxy build blocks at boot when no relay is connected, which is why networking is a separate bundle used only when a relay is configured.

In Node, set globalThis.WebSocket = require('ws') before loading the module (the built-in undici WebSocket doesn't complete the handshake). In a browser the native WebSocket is used directly. A benign SocketException: Bad file descriptor can appear at connection teardown after the body is fully read.

Gotchas

For a higher-level, capability-driven API (auto tier selection, jar staging, persistent storage, networking), the framework also ships an SDK — wasmjvm.js, new WasmJVM({...}).start() — used with the modular-pack artifacts.