A browser playground that runs the rontolisp interpreter and compilers entirely client-side. rontolisp itself is compiled to WebAssembly with the GraalVM Native Image Web Image backend, so the page has no server-side component.
It offers:
- REPL — interpret expressions in a persistent environment (definitions and
variables survive across inputs). Input history is kept in
localStorage(it survives page reloads) and is navigated with the Up/Down arrow keys. The input area accepts multi-line expressions: Shift+Enter inserts a newline, Enter evaluates. - Compile to JVM — compile the source to a
.classfile and download it. The downloaded file runs on a real JVM (java Main). - Compile to WASM — compile the source to a
.wasmmodule and download it. The downloaded file runs on a real wasm runtime (wasmtime --wasm gc output.wasm).
A companion page, compile-run.html ("compile & run WASM in the browser"),
closes the loop in two phases, both client-side:
- Compile & Load — compile a set of Lisp definitions to a
.wasmmodule (samerontoCompileWasm) and keep it. A tiny driver,(print (eval (read))), is appended so the module can apply a call read from stdin. - Execute — enter a function name and arguments; the page builds a call
expression (e.g.
(fib 20)), feeds it to the kept module on stdin, and runs it in your browser's own WebAssembly runtime through the WASI shim fromexamples/browser/wasm-browser/(runWasmModule), showing the printed result. The module is compiled once and reused, so changing the arguments and calling again does not recompile.
Because the call is passed as Lisp source through the module's built-in
read/eval, the value-representation barrier (Lisp values are WASM GC
references, not JS numbers) is sidestepped: arguments are ordinary Lisp
(literals, (list 1 2 3), nested calls), subject to the compiled eval's
limits. So the user program is both compiled to WASM and executed entirely in
the browser, with no download and no server.
Download .wasm saves the loaded module (loaded.wasm, definitions plus the
(print (eval (read))) driver). The page includes a "Run the downloaded
loaded.wasm outside the browser" section: because the driver reads the call
from stdin, you run it by piping a call expression in. The same module works on
either runtime:
# wasmtime (needs WebAssembly GC: -W gc)
echo '(fib 20)' | wasmtime run -W gc loaded.wasm
# Node.js 22+ — save run.mjs (node:wasi over the eight preview1 imports),
# then pipe a call to it
echo '(fib 20)' | node run.mjs loaded.wasmThe source and REPL panes are resized by dragging the divider between them,
the REPL input area is resized by dragging the bar above it (both positions
are remembered in localStorage), and the sample selector loads
ready-to-run programs covering recursion, higher-order functions, closures,
string operations, format, list/association-list operations, setf/push/pop,
math (including exact rationals), eval, and loops.
playground.html (browser UI: REPL + download buttons + fetch broker)
| postMessage RPC (vmCall)
v
ronto-worker.js (Web Worker hosting the runtime; blocks on Atomics.wait for fetch)
| importScripts
v
rontoplayground.js + .wasm (rontolisp compiled to WASM by Web Image)
| wraps
v
RontoPlayground.java (@JS bootstrap that exports 3 functions to JS)
| delegates to
v
LispEvaluator / JvmLispCompiler / WasmLispCompiler (the existing core)
The interpreter runs inside a Web Worker (ronto-worker.js), so long
evaluations do not freeze the page and rontolisp:fetch is truly asynchronous:
the worker posts each request (with a growable SharedArrayBuffer) to the main
thread, whose broker runs the real browser fetch() concurrently — multiple
requests overlap — and rontolisp:await blocks the worker with Atomics.wait
until the response bytes land in the buffer. SharedArrayBuffer needs
cross-origin isolation (COOP/COEP); GitHub Pages cannot send those headers, so
coi-serviceworker.min.js (vendored, MIT) supplies them via a service worker
(one automatic reload on first visit). Without isolation the playground falls
back to a synchronous XHR per request — same behavior, no overlap.
compile-run.html reuses the same rontoplayground.js runtime but adds the
in-browser execution step:
compile-run.html
| Phase 1 (once): globalThis.rontoCompileWasm(definitions + "(print (eval (read)))")
| -> Base64 .wasm bytes (client-side), kept in memory
| Phase 2 (per call): runWasmModule(bytes, { stdin: "(fib 20)\n" })
v (./wasm-browser/wasi-shim.js)
browser WebAssembly runtime -> the module's read+eval applies the call,
print writes the result -> shown on the page
The WASI shim is the same file the standalone wasm-browser/ example serves; it
gained a runWasmModule(bytes, opts) entry point (the existing runWasm(url) is
now fetch + runWasmModule) so a module already in memory needs no .wasm
file to fetch.
src/web/java/am/ik/rontolisp/web/RontoPlayground.java depends on the
GraalVM-only org.graalvm.webimage.api module, so it is kept in a separate
source root. The web Maven profile adds src/web/java to the build (via
build-helper-maven-plugin); the normal build and non-GraalVM JDKs never see it.
RontoPlayground installs three callables on the JavaScript global scope using
the @JS annotation (@JS.Export is not implemented in Web Image yet, so the
bootstrap-helper pattern is used). Compiled bytes cross the JS boundary as
Base64 strings; the front-end decodes them into a Blob for download.
Compilation errors are returned as strings prefixed with ERROR:.
- GraalVM with the Web Image (
svm-wasm) tool providingnative-image. Verify withnative-image --tool:svm-wasm --help. - Binaryen (
wasm-as) version 119+ onPATH(brew install binaryen). - A recent browser with WebAssembly GC and exception handling (verified on
Chrome 149). For Node.js, run with
node --experimental-wasm-exnref.
./mvnw -Pweb -DskipTests packageThe web profile compiles src/web/java together with the rest of the project,
then runs the native-maven-plugin with --tool:svm-wasm to compile rontolisp
to WebAssembly, and stages rontoplayground.js, rontoplayground.js.wasm,
playground.html (the UI), ronto-worker.js (the Web Worker host),
coi-serviceworker.min.js (COOP/COEP for GitHub Pages), and index.html (a
redirect to the docs) into web/dist/. Build with a GraalVM that has the
svm-wasm tool.
Profile-specific details (all confined to the web profile):
- The release flag is cleared (
maven.compiler.releaseis emptied) and-parametersis enabled —--releasehides theorg.graalvm.webimage.apimodule, and@JSneeds parameter names in the bytecode. - jline stays on the classpath only so
JLineReplkeeps compiling; it is unused by the playground. Its embeddedMETA-INF/native-imageconfig is excluded (--exclude-config), which otherwise force-includes jline and makes the wasm roughly 5x larger.
The page must be served over HTTP (the .wasm is fetched relative to the page):
cd web/dist
jwebserver -p 8000 # or: python3 -m http.server 8000
open http://localhost:8000/playground.html # / redirects to the docs.github/workflows/pages.yaml builds the playground and publishes web/dist to
GitHub Pages on every push to develop (and on manual workflow_dispatch).
All asset references are relative, so it works under the project subpath:
The package phase also stages two checked-in examples as subpaths of the same
site (no compilation needed — their .wasm files are committed and their
index.html uses relative URLs):
/wasm-browser/— prebuilt WASM run in plain HTML/JS (fromexamples/browser/wasm-browser/)./hiragana/— handwritten-hiragana recognition demo (fromexamples/browser/hiragana/; only the five runtime files the browser loads —index.html,wasi-shim.js,glyphs.js,infer.wasmand the trainedweights.binthe module reads at startup — are staged; the offline training artifacts are excluded).
One-time repo setup: Settings -> Pages -> Build and deployment -> Source: "GitHub Actions".
read(stdin) is unavailable in the WASM sandbox; the REPL covers the interpreter's evaluation features. See the project README "Compiledevallimitations" for the compiled backends.rontolisp:fetch(HTTP) works in the browser, but through a different transport.java.net.httpcannot be compiled by Web Image (it needs the TLS/security stack and host sockets), and Web Image has neither JS Promise Integration nor threads, so a WASM guest cannotawaita browser Promise directly. Instead, a Web Image substitution (src/web/java/.../eval/Target_HttpSupport.java) hands the request to the main-thread fetch broker over aSharedArrayBuffer(src/web/java/.../web/BrowserHttp.java) andawaitblocks the worker withAtomics.wait— requests genuinely overlap. On the main thread (e.g.compile-run.html) or without cross-origin isolation it falls back to a synchronousXMLHttpRequest. Either way requests are subject to the browser same-origin policy / CORS (cross-origin targets must sendAccess-Control-Allow-Origin, and only "simple" response headers appear in:headersunless the server setsAccess-Control-Expose-Headers); a blocked or failed request surfaces as an error when the promise is awaited. (Compiling afetchprogram to JVM still works; compiling to WASM needs--component, which the playground does not emit.)loadworks against uploaded files: pick (or drag-and-drop).lispfiles with the load files control, then(load "name.lisp")resolves them from an in-memory map. The browser has no real filesystem, so the playground installs an in-memorySourceLoader(globalThis.rontoPutFile(name, content)feeds it) instead ofFiles.readString.- Generated artifacts (
web/dist/, andtarget/rontoplayground.*) are git-ignored.