playground

An in-browser container playground: a dashboard of WASI containers,
images and volumes, with a terminal running wctr, for cloud and
container training labs. A learner types container commands in a web page
and sees what they do — no virtual machine, no server: the
engine runs in the page.
Open it: https://go-wasmcontainers.github.io/playground/ — the
dashboard (Containers,
Images, Volumes, Terminal; logs and stats of the selected container, stop,
start and remove buttons that print the wctr command they ran). The
terminal alone, on xterm.js, stays at
?ui=terminal.
Status: M1. wctr pull, run (--rm, -d, --name, -e, -w,
-m, --entrypoint), ps, logs, start, diff, rm, rmi,
images, inspect, kill, system df, system prune, with the same
semantics and exit codes as the native engine, tested by typing them into
the page in Chromium and Firefox. Images and containers survive a reload;
the page works offline. Containers talk to each other over TCP and UDP,
by name (Networking). No shell (one wctr command per
line), no stdin yet.
Try
$ wctr run --rm hello
Unable to find image 'hello' locally
…
Hello from a WebAssembly container!
$ wctr run --rm -e NAME=you greet
Hello, you!
$ wctr run -d --name counter lab count 5
$ wctr logs -f counter
$ wctr run --name failing lab exit 42
bye
[exit status 42]
$ wctr ps -a
$ wctr run --rm shfmt --version
$ wctr images
help lists the commands and the images; clear clears the screen; Ctrl-C
stops waiting for a command.
Line editing, as in bash. Tab completes commands, flags, flag values,
images (local ones for run, the catalogue for pull), container names and
IDs, and lab's commands; a unique match gets a trailing space, several
complete to their common prefix, and Tab Tab lists them in columns with
their descriptions (asking first beyond 100). Completion works mid-line and
inside quotes. Up/Down and Ctrl-R search the history, kept for the tab's
session; Left/Right, Home/End, Ctrl-A/E/B/F, Ctrl-U/K/W, Ctrl-D and Ctrl-L
edit. Measured from the key press to the completed line on screen (sampled
every 5 ms): 7–14 ms in Chromium, 23 ms in Firefox (CI runner).
It keeps your work. Pulled images, containers (state, exit code, logs,
the files they wrote) live in the browser's IndexedDB: a reload, or coming
back later, finds them as you left them. A container that was running is
then exited with status 137, and a --rm one is gone, as after a daemon
restart (wctr diff NAME shows what a container wrote; wctr start NAME
runs it again). The page itself is kept by its service worker, so a reload
works offline; a new version installs in the background and is used only
when you click "Reload to update". One tab runs the engine: a second tab
of the same browser offers to take over. wctr system df shows what is
stored and the browser's quota; wctr system prune -f (-a for unused
images too) frees it. After the first pull or run the page asks the
browser to keep its storage (navigator.storage.persist()): Chromium and
Safari decide without asking, Firefox asks you.
Measured in Chromium (CI numbers are in each run's summary): the engine is
ready in 130–190 ms on a first visit, 80–115 ms after a reload, 75 ms
offline. Compiled modules are not kept across reloads by default: measured,
compiling a 2.8 MB module from Cache Storage with compileStreaming takes
8.4 ms against 1.5 ms from bytes, and the guest runs no faster for it
(?codecache=1 turns it on).
Images: hello, greet (a CNCF wasm OCI artifact), lab (a toolbox:
echo env pwd ls cat write count sleep spin hog bench fs exit esc),
mnist (reads a handwritten digit with wasi-nn: wctr run --rm mnist
prints digit 7 (p=1.000), in about 80 ms in Chromium; the engine worker
serves wasi_ephemeral_nn with the pure-Go CPU backend of
go-wasmcontainers/gpu v0.5.1;
wctr run --rm --gpu mnist gpu runs it on the page's WebGPU, and without
--gpu the GPU target fails with "unsupported operation", it never falls
back to the CPU. The model is MNIST-12 from the ONNX Model Zoo (MIT); the picture is
picture 0 of the MNIST test set by Y. LeCun, C. Cortes and C. J. C. Burges
(CC BY-SA 3.0). The image carries both notices at
/usr/share/doc/mnist/NOTICE), and these:
compute: wasi:webgpu compute. wctr run --rm --gpu compute runs a
1M-float vector add and a 256x256 matrix product on the GPU, and checks
both against the CPU.
triangle: wasi-gfx:surface. wctr run --rm --gpu triangle [frames]
draws a spinning triangle on a surface. In the dashboard the surface is a
pane under Surfaces; the page acknowledges each frame, and the engine
paces the guest on those acknowledgements.
GPU access is opt-in per container. A container without --gpu, or a
browser without WebGPU, gets no adapter: these images print
no GPU adapter and exit 2. Software (fallback) adapters are refused.
-
shell: go-wasmcontainers/shell
0.1.0, mirrored verbatim from ghcr.io and pinned by digest
(go run ./cmd/site pin REF prints the digest to pin). It contains sh
and 42 coreutils. Programs started by the shell run as WASIX processes in
the browser:
$ wctr run -d --name c shell sleep 600
$ wctr exec c sh -c 'ls /bin | wc -l'
43
$ wctr exec -it c sh
In the terminal, a wctr exec -i line gets what you type, Ctrl-C and
Ctrl-D included. wctr build and wctr compose are wired in as in
cmd/wctr.
Last, pkgx wasip1 bottles taken from ghcr.io/go-pkgx/packages at build time:
the Go ones age 1.3.2, go-md2man 2.0.7, shfmt 3.14.1, and the C ones
lua 5.4.9, sqlite3 3.53.4 and minigzip (zlib 1.3.2), whose layers are
tar+zstd, served as published. lua raises its errors with WebAssembly
exception handling, which runs in Chromium and Firefox (tested) and natively:
$ wctr run --rm lua -e 'print(pcall(function() error("boom") end))'
false (command line):1: boom
$ wctr run --rm sqlite3 :memory: 'select 6*7;'
42
Networking
$ wctr run -d --name web net serve
$ wctr run --rm net get http://web/me
hello me from 10.88.0.3:49153
$ wctr run -d --name echo net udp-echo
$ wctr run --rm net udp echo:7 ping
reply: ping
$ wctr run --rm net lookup nobody
net: lookup nobody on 127.0.0.1:53: no such host
$ wctr network create lab && wctr run -d --network lab --name db net serve
Every container joins the bridge network (or --network NAME; wctr network create|ls|inspect|rm), in the page: the engine (v0.10.0) serves
the WasmEdge socket calls of a guest's WASI imports and answers its DNS
queries with the network's names, so a stock Go program using
stealthrocket/net runs unchanged —
the net image is one (serve, get, bench, udp-echo, udp,
lookup, dial). Nothing leaves the page: there is no route to the
Internet or to the machine.
Measured (net bench: 200 sequential GETs, then one 8 MiB GET, between two
containers on the bridge), Apple M4 Max, load 16–19 so indicative:
|
latency p50 / p90 |
throughput |
| Chromium (the page) |
1.1 / 1.4 ms |
106–112 MB/s |
native: the same engine on wazero (perf job) |
0.33 / 0.39 ms |
1.2 GB/s |
native: Go net/http over loopback TCP (upper bound) |
0.05 / 0.07 ms |
3.8 GB/s |
The browser is ~3× slower in latency and ~11× in throughput than the same
engine natively: every socket read or write is a WASI call that crosses
from the container's worker to the engine's (a SharedArrayBuffer round
trip, ~40–100 µs). Larger reads per call are the open item.
Join class. With a teacher's
relay, the page joins the
class network: in the dashboard's Networks view (Alt+4), Join class…
asks for the relay's address (https only; the page reloads once), then your
name and the join code (on the ?ui=terminal page, the "Join class" link
under the header does the same). A class network then
appears (wctr run --network class …): your containers get the class's
addresses and names (web.<you>.<domain>), reach your classmates' under
the teacher's firewall, and are reached by them. "Leave class" ends the
session.
- The engine worker alone talks to the relay: the service worker extends
that worker's CSP
connect-src to the one relay origin you entered
(https and wss), and the page keeps connect-src 'self' (both tested).
- The join code is a secret: typed in a password field, posted once to the
engine worker, cleared; never stored, logged, put in a URL or the
history (the e2e checks every place the page keeps things). The session
token is kept in IndexedDB so a reload rejoins without the code, and is
deleted by "Leave class".
- The relay's certificate must be one the browser trusts: a publicly
trusted one (
wctr-relay serve --tls acme), or the classroom CA
(wctr-relay ca export) installed in the browser or the system. A page
cannot pin a CA itself; Firefox keeps its own certificate store, so
there the classroom CA goes in Firefox's settings (or an enterprise
policy), not only the system's.
- The teacher runs the relay for the playground's origin:
wctr-relay serve --cors-origin https://go-wasmcontainers.github.io
(relay v0.3.0 or later; repeatable, * refused). That allows the join
calls' CORS preflight and the data channel's Origin check. Without it,
joining fails as a bare "NetworkError" (in Firefox first: it sends the
Connect client's User-Agent, which the preflight must allow). The e2e
runs a real relay configured that way.
- The page and the dashboard meet through
window.__wctrClass (relay,
useRelay(origin), status()), the protocol of the dashboard's README
("Join class"); the e2e drives the dashboard's own dialog, one key at a
time, and checks the code is in no URL, storage, DOM or test hook.
Coming: -p in the browser, on an origin of its own. wctr run -p 8080:80 net serve will open the container's web pages in a tab — but
not on this origin: a container's page here could read the
playground's storage (the class session token) and script it, and a
sandboxed page cannot be served by a service worker at all (an opaque
origin has no active service worker, Service Workers §2.5.1; measured in
Chromium: its stylesheets, scripts, images and fetch all bypass the
worker). So containers' pages live on another site,
go-wasmcontainers-usercontent.github.io (github.io is on the Public
Suffix List):
playground tab (this origin, isolated: the engine)
▲ BroadcastChannel, per-request ids bound to a nonce the playground issued
view tab (this origin, not isolated, not frameable)
└─ iframe <usercontent origin>/shell.html?s=<session>#<nonce>
└─ iframe /s/<session>/8080/… ← the container's page; its requests go to
the usercontent service worker → shell → view → engine → portmap.Virtual
- The shell (
internal/usercontent, written by site usercontent --allow <playground origin>; each <org>-usercontent Pages repository
runs it, so one implementation serves several playgrounds) talks to the
pinned origin only, accepts only messages carrying the session's nonce
(read from its URL fragment, then removed from it), refuses to run
outside that origin (ancestorOrigins, else the referrer; its service
worker adds frame-ancestors once installed, as Pages sends no
headers), and caches nothing.
- What a container may say is decided in Go (
internal/portroute,
fuzzed): methods, paths and header fields bounded; Cookie, Host and
forwarding fields from the page dropped, X-Forwarded-Host/Proto/Prefix
set; Set-Cookie, Clear-Site-Data, Service-Worker-Allowed,
hop-by-hop and the origin's own policies never reach the browser;
Location to localhost:<port> rewritten to that port's path, so an
application can send the browser to an identity provider in another
container. A service worker can neither see nor set cookies, so the
engine keeps a cookie jar per session (sessions work; document.cookie
in the container's page does not see them).
- The shell is not a boundary against the container's own pages (they
share its origin); what they reach through it is HTTP to this session's
published ports, nothing of the playground.
The shell and the policy are here and tested (go test -tags e2e ./internal/usercontent/: three local origins in Chromium); the view tab
and the engine worker's side follow with the engine release that has
portmap.Virtual.
How it works
page (main thread) engine worker (GOOS=js) container workers
dashboard.wasm (canvas) ──► API over postMessage (dashboard bridge) ─► daemon
its Terminal: session (wctr) in the page, API over the same bridge
or ?ui=terminal:
xterm.js (keys, raw) ─────► console: engine's cli/lineedit + session
session: wctr command tree (in-process)
▲ filtered output └─► API (in-memory transport) ─► daemon
└────────────────────────── image store (IndexedDB) ◄─ /v2 mirror (same origin)
browser executor ── one Worker per container ──► guest.wasm
package wasi ◄── SharedArrayBuffer + Atomics.wait ── WASI imports
- The dashboard rides the same engine.
dashboard.wasm (go-widgets on
a <canvas>, 20 MB) is downloaded and compiled while the engine worker
starts, and runs once the engine is ready; it speaks the engine's HTTP API
to the worker through the dashboard's postMessage bridge and follows
/events. Its Terminal runs the playground's session (wctr, help,
clear, the image catalogue for pull) in the page. The service worker
caches it with the rest of the shell, so the dashboard works offline.
- One engine, in a worker. The engine's daemon, its API (a subset
compatible with the Docker Engine API) and the
wctr command tree run in
one dedicated worker built GOOS=js; wctr reaches the API through an
in-memory transport (a browser has no unix sockets). The page only
forwards keys and prints: editing, history and completion are the
engine's pure-Go line editor (cli/lineedit), which completes through the
same cobra __complete path as wctr completion bash, in-process, and is
tested natively.
- One worker per container. The guest is compiled once per image
(
WebAssembly.compile, cached by digest and memory limit) and posted to a
fresh worker. Its wasi_snapshot_preview1 imports block in
Atomics.wait while the engine's worker serves them with the engine's
own WASI host — the same Go code the native engine runs in its tests — so
there is one file system per container, the copy-on-write tree the
engine already has, and volumes later need nothing new. Measured: a WASI
call (stat) round-trips in ~38 µs.
- Limits as natively. The guest's memory is capped in the binary (the
engine rewrites the module's memory limits, which also makes it shared so
the engine can read it); the wall-clock limit terminates the worker (exit
137); logs, written bytes and the number of containers are bounded.
The preopened
/ is the container's root and PWD its working
directory, which Go's wasip1 port needs to resolve relative paths.
- Images, same origin. ghcr.io sends no CORS headers and the page's CSP
allows its own origin only, so CI publishes a static, read-only
/v2
mirror with the site (/playground/v2/library/<name>/…); wctr pull hello resolves docker.io/library/hello to it. A pkgx bottle becomes an
image without being repacked: its tar+gzip layer as is, plus a config
whose Entrypoint is the bottle's bin/<name>.wasm. Pulled blobs are kept
in IndexedDB, verified again when used, so a reload keeps the images.
- Cross-origin isolation on GitHub Pages. Shared memory needs COOP/COEP,
which Pages cannot send;
sw.js (after coi-serviceworker) re-serves the
site's own responses with them and with the CSP, at the cost of one reload
on the first visit.
Security
- The page and its workers fetch their own origin only: CSP
default-src 'self', connect-src 'self', script-src 'self' 'wasm-unsafe-eval' (WebAssembly, no eval), no inline script; xterm.js
is served from the site (fetched from npm, checked against the registry's
sha512).
- Container output is hostile: before it reaches xterm.js the engine keeps
text,
\t \n \r \b, colours (SGR) and erase-in-line, and drops every other
escape (OSC 52 clipboard writes, OSC 8 links, title and report sequences
that echo back as input, DCS/APC/PM/SOS), C1 controls and invalid UTF-8;
output is rate-limited (256 KiB/s, 1 MiB burst). The filter is fuzzed, and
the browser test prints an OSC 52 and a javascript: link from a
container and checks neither reaches the terminal.
- Each container is a worker with no DOM; its only door to the engine is the
WASI host, which bounds every argument (fuzzed in the engine).
The same hello module (2.4 MB, Go wasip1), on one GitHub-hosted
ubuntu-latest runner in one CI run (e2e and perf jobs); browser
numbers are from Enter to the prompt, including compile:
|
Chromium (headless) |
Firefox (headless) |
Native: engine on wazero |
Native: wasmtime 50 |
| engine ready (page load to prompt) |
0.46 s |
1.0 s |
— |
— |
first run (compile + run) |
81 ms |
291 ms |
825 ms |
23 ms (wasmtime run, a process) |
next run (compiled module cached) |
47 ms |
231 ms |
5.4 ms |
— |
WASI round trip (lab bench: one stat) |
97 µs |
354 µs |
— |
— |
On an Apple M4 Max (Chromium, local): ready 0.25 s, first run 31 ms, next
15 ms, WASI round trip 38 µs; the native engine 430 ms / 2.3 ms.
The browsers compile the guest lazily (a baseline tier first), wazero
compiles it ahead of time, which is why the engine's native cold start is
the slowest here and wasmtime's the fastest; a warm start in a browser pays
a worker's creation and instantiation, more so in Firefox. A compiled
WebAssembly.Module cannot be stored in IndexedDB (measured:
DataCloneError in both browsers), so container modules compile once per
page load.
Building and testing
$ go run ./cmd/site build --out _site --pkgx --remote # images + mirror (--remote: the pinned shell), engine.wasm, the page
$ go run ./cmd/site serve --dir _site # http://127.0.0.1:8080/playground/
$ go test ./... # native: session transcript, filter, mirror
$ go run ./cmd/site playwright-driver --dir /tmp/pwd # once: driver from npm playwright-core
$ PLAYWRIGHT_DRIVER_PATH=/tmp/pwd PLAYWRIGHT_NODEJS_PATH=$(command -v node) \
go run github.com/mxschmitt/playwright-go/cmd/playwright install chromium firefox
$ PLAYWRIGHT_DRIVER_PATH=/tmp/pwd PLAYWRIGHT_NODEJS_PATH=$(command -v node) go test -tags e2e ./e2e/
The e2e has three opt-in parts:
E2E_REMOTE=1 mirrors the shell image and runs TestShell: exec, a
pipeline and exec -it typed into the page.
E2E_GPU=1 runs TestGPU in Chromium (channel chromium):
compute matches the CPU;
triangle's frames reach the page and change;
- without
--gpu, there is no adapter.
E2E_GPU_REAL=1 makes a software adapter fail the test. CI sets it on
the macOS lane, which runs on the Apple GPU. The Linux lane has only
SwiftShader on Vulkan
(E2E_GPU_ARGS="--enable-features=Vulkan --use-vulkan=swiftshader --use-angle=swiftshader").
The engine refuses that software adapter even with --gpu, and the
Linux lane checks the refusal.
The pages workflow publishes the site, then cmd/site verify waits until
every file served at the live URL hashes like the build (Pages caches for
600 s), then the same e2e session is typed into the live page in Chromium
(E2E_URL=https://go-wasmcontainers.github.io/playground/ go test -tags e2e -run 'TestLive|TestDashboard' ./e2e/).
TestDashboard drives the dashboard as a person does — one key press per
character, clicks at the coordinates the dashboard reports for its controls
(window.__dashboard.state()), never pixels: a run typed in its Terminal
appears in Containers, its logs show, the Stop button stops it, a reload
restores it, and (service worker) an offline reload brings the dashboard
back. Chromium (service worker and headers) and Firefox (headers) in CI, and
Chromium against the live page: Playwright's Firefox runs no service
workers, and GitHub Pages cannot send the isolation headers, so there is no
live Firefox run.
Limitations
- One
wctr command per line, no shell, no pipes; containers have no
stdin. Networks stay inside the page (no Internet, no published ports
yet).
- A running container's writes are stored every 2 s: a reload loses at
most the last 2 s of them. Storage is best effort unless the browser
grants
persist(): under disk pressure it may evict the whole origin.
- When a second tab takes over, the first tab's engine stops at once; a
write it had under way is lost (each write is one IndexedDB
transaction, so nothing is left half-written).
- Interrupting
wctr run with Ctrl-C stops waiting; the container keeps
running (wctr kill).
Links
Licence
BSD-3-Clause. web/vendor/ holds xterm.js and its fit addon (MIT, licences
alongside).