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.
| Bundle | Factory | Capabilities |
|---|---|---|
| jvm-base | createJVM | plain Java + in-VM javac |
| jvmawt | createJVM | Swing / AWT / Java2D → canvas |
| jvmgl | createJVMGL | AWT + 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:
| File | Effect |
|---|---|
/work/classpath | one line → -Djava.class.path (default /app) |
/work/addmods | one line → --add-modules |
/work/args | N lines → program args |
/work/vmopts | N 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:
Runner— compiles, runsmain, exits. One-shot.ReplServer— stays resident and keeps the VM warm: it loops on a/work/reqcounter, recompiling/running on each bump and writing the request id to/work/respwhen done. First run boots (~5s); later runs ~1s.
// 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.
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
- Monolithic vs. packs. A bundle either bakes its JDK image (monolithic, fed via
getPreloadedPackage) or loads modular packs at boot — not both. Writing module files into a monolithic bundle's/jdkor/appat boot double-stages and freezes with anmknod EEXIST. - Serve
.gzraw. Don't setContent-Encoding: gzipon the data image — the page decompresses it itself withDecompressionStream. - First load is large (tens of MB); it caches after that.
wasmjvm.js,
new WasmJVM({...}).start() — used with the modular-pack artifacts.