Client-side Scala 3 for LiveCodes: compile and run Scala entirely in the browser, no server.
This is a thin adapter. The compiler is scala-toolchain-wasm —
the Scala 3 compiler and the Scala.js linker compiled to WebAssembly, with a browser host runtime that
ships inside the distribution. We pin a release, verify it, ship it, and adapt its API to the shape
LiveCodes consumes.
const runtime = await ScalaWasm.createRuntime(); // baseUrl defaults to the bundle's own URL
await runtime.warmUp(); // optional: move the one-off cost off the first run
const result = await runtime.run(`
@main def hello(): Unit =
val squares = (1 to 5).map(n => n * n)
println("squares: " + squares.mkString(","))
`);
// result: { ok, output, error, exitCode, compileMs, linkMs, runMs, diagnostics }
// stdin is a preloaded buffer:
const echo = await runtime.run(
'@main def echo(): Unit = println(scala.io.StdIn.readLine())',
{ input: 'hello\n' },
);
// echo.output === "hello"
runtime.destroy();dist/ and vendor/ are committed, so a fresh clone runs as-is. Only Node 18.17+ is required:
git clone https://github.com/live-codes/scala-wasm.git
cd scala-wasm
npm start # http://localhost:8932/poc/Open http://localhost:8932/poc/ for the automated smoke cases, or
http://localhost:8932/playground-scala.html for the interactive playground.
npm test also needs no install: it verifies the committed toolchain against the pinned
sha256 checksums (npm run verify) and checks the bundle parses and keeps its capability gate.
Rebuilding is only necessary after changing src/ or bumping the toolchain — see Build.
It shares the interface with @live-codes/browser-jvm (the createRuntime → run(source, {input})
shape, npm/jsDelivr delivery, lazy per-language loading) but no tooling: different artifacts, its
own host runtime/worker/virtual-FS/module loader, a different browser gate (JSPI vs WasmGC), a
different licence and a different upstream. LiveCodes' convention is a package per language toolchain
(fsharp-wasm, rust-wasm, go-wasm, …), and this follows it.
Point baseUrl at the directory containing toolchain/ and scala-wasm-worker.iife.js — normally
.../dist/:
<script src="https://cdn.jsdelivr.net/npm/@live-codes/scala-wasm@0.0.1/dist/scala-wasm.iife.js"></script>
<script>
const runtime = await ScalaWasm.createRuntime({
baseUrl: 'https://cdn.jsdelivr.net/npm/@live-codes/scala-wasm@0.0.1/dist/',
});
</script>Browsers forbid new Worker() on a cross-origin URL, and a CDN URL is cross-origin. The package
therefore also ships the host worker pre-bundled as dist/scala-wasm-worker.iife.js. The adapter
bootstraps a classic worker from a small data: URL containing importScripts("<worker URL>") — not
a blob, and not a copy of the worker source. No COOP/COEP headers, no worker proxy, and no same-origin
hosting are required. A host with a restrictive Content-Security-Policy must allow worker-src data:.
This package never imports another engine. When it cannot run a program — or cannot fake the stdin it was given — it refuses with a typed error and leaves the response to the host:
| Situation | Behaviour |
|---|---|
| No JSPI (Firefox, Safari) | createRuntime() throws ScalaUnsupportedError |
Stdin with an object main or parameterless @main |
faked from options.input |
Stdin with a parameterized @main |
run() throws ScalaStdinUnsupportedError |
runtime.capabilities reports supported, missingFeatures, supportsWasmTarget, supportsMacros,
versionMismatch and the toolchain versions, so the decision can be made before loading anything heavy.
Pass input to run. The adapter discovers the entry point, then compiles a launcher that calls it
inside Console.withIn and sets System.in:
scala.io.StdIn.readLine()(and the typedreadInt/readDouble/… methods),scala.Console.in,System.in.read(), andBufferedReader(new InputStreamReader(System.in))read the supplied buffer.- The buffer is one-shot and non-interactive. Once exhausted,
readLine()returnsnulland the typedStdInreaders throwEOFException. - It works with
object Main { def main(args: Array[String]) }and parameterless@mainmethods, including in packages. A parameterized@maincannot be called from the launcher and raisesScalaStdinUnsupportedError. java.util.Scannerandscala.io.Source.stdinhave no working Scala.js implementation.
Chrome / Edge 137+ only — the compiler is built with the Scala.js WebAssembly backend and needs
JSPI (WebAssembly.JSTag / Suspending / promising). No SharedArrayBuffer, so no COOP/COEP
headers are required.
| first | steady state | |
|---|---|---|
| compile | ~1.4 s | 26–36 ms |
| link | ~0.6 s | ~0.45 s |
| run | ~5 ms | ~5–10 ms |
| edit→run wall | ~2.0 s | ~0.5 s |
Cold to interactive is init + warmUp() (the one-off classpath scan and runtime-IR parse) ≈ 9.5 s
excluding download. A macro-free Wasm session downloads 46.5 MB; the 23 MB macro IR is fetched only
if a program actually uses macros.
- Stdin is emulated, not interactive.
options.inputis preloaded intoConsole.inandSystem.in, soscala.io.StdIn,System.in, andBufferedReaderwork.java.util.Scannerandscala.io.Source.stdindo not (Scala.js library gaps), and parameterized@mainmethods cannot be wrapped. - Scala 3 only. (The pinned toolchain is Scala 3.8.3 / Scala.js 1.20.2.)
- Macros work but cost ~80 s the first time (a second linked compiler + 22 MB of IR).
- Single-maintainer upstream, no npm package — hence the pinned tag and per-asset checksums.
A clone needs none of this. Rebuild when you change src/ or bump the pinned toolchain.
npm install
npm run vendor:scala # fetch the pinned release (~58 MB tarball, resumable), verify sha256, extract
npm run build # esbuild the adapter + worker bundle, copy the distribution into dist/toolchain/
npm test # verify committed checksums + static smoke (runs without install too)
npm run check:dist # fail if either committed bundle no longer matches its source
npm run serve # http://localhost:8932/poc/npm run vendor:scala is a no-op once the pinned distribution is vendored; pass --force to
re-download and re-extract. prebuild runs it automatically, so after npm install,
npm run build is enough.
Open poc/scala.html for the browser smoke (hello, case class, structured diagnostics, stdin refusal).
npm publish runs prepublishOnly (npm run check:dist && npm test), so a stale bundle or a checksum
mismatch blocks the release. publishConfig.access is public; the package is scoped and would
otherwise default to restricted. Check the tarball with npm pack --dry-run — roughly 58 MB, almost all
of it the pinned toolchain.
| Artifact | Source | Licence |
|---|---|---|
dist/toolchain/compiler/main.wasm, main.js, __loader.js |
scala-toolchain-wasm v0.3.4 (univalence-xyz/scala3-on-wasm@8fdbb99d) |
Apache-2.0 |
dist/toolchain/host/*.js, vendor/*.js |
same | Apache-2.0 |
dist/scala-wasm-worker.iife.js |
bundled build of the same host worker | Apache-2.0 |
dist/toolchain/classpath/rt.jar |
OpenJDK java.base from the build JDK |
GPLv2 + Classpath Exception |
dist/toolchain/classpath/scala-lib.jar |
Scala 3 library | Apache-2.0 |
dist/toolchain/classpath/scalajs-lib.jar, runtime/runtime-sjsir.zip |
Scala.js | Apache-2.0 |
dist/scala-wasm.iife.js and the adapter source are MIT (LICENSE). Redistributed binaries keep their
upstream licences: Apache-2.0 text in licenses/Apache-2.0.txt, GPLv2 + Classpath Exception text in
licenses/GPL-2.0-with-Classpath-Exception.txt, and the attribution table plus the upstream NOTICE in
NOTICE. All of these ship in the npm package, and every redistributed byte is covered by the per-asset
checksums in dist/toolchain/CHECKSUMS.json (npm run verify).