playground

module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 28, 2026 License: BSD-3-Clause

README

playground

Docs CI Pages status

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.2.0, and mnist gpu fails with "unsupported operation" until a WebGPU backend lands. 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 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.

Planned: -p in the browser. wctr run -p 8080:80 net serve would make the service worker route /playground/port/8080/… to the container's port 80 (an HTTP proxy through the engine worker), so a learner opens their web server in a tab or an iframe of the page. Not implemented yet.

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).

Performance

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          # images + mirror, 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/playwright-community/playwright-go/cmd/playwright install chromium firefox
$ PLAYWRIGHT_DRIVER_PATH=/tmp/pwd PLAYWRIGHT_NODEJS_PATH=$(command -v node) go test -tags e2e ./e2e/

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).

Licence

BSD-3-Clause. web/vendor/ holds xterm.js and its fit addon (MIT, licences alongside).

Directories

Path Synopsis
cmd
dashboard command
Command dashboard is the playground's main UI (GOOS=js, in the page): the go-wasmcontainers dashboard (github.com/go-wasmcontainers/dashboard) on the #screen canvas, speaking the engine's API to the engine worker the page created (window.__wctrEngine) through the dashboard's bridge.
Command dashboard is the playground's main UI (GOOS=js, in the page): the go-wasmcontainers dashboard (github.com/go-wasmcontainers/dashboard) on the #screen canvas, speaking the engine's API to the engine worker the page created (window.__wctrEngine) through the dashboard's bridge.
engine command
Command engine is the playground's in-browser engine; it builds only for GOOS=js (see main_js.go).
Command engine is the playground's in-browser engine; it builds only for GOOS=js (see main_js.go).
mirror command
Command mirror builds the playground's static image registry (a /v2 tree served by GitHub Pages) and inspects the pkgx bottles it carries.
Command mirror builds the playground's static image registry (a /v2 tree served by GitHub Pages) and inspects the pkgx bottles it carries.
site command
Command site builds, serves and verifies the playground's static site.
Command site builds, serves and verifies the playground's static site.
images
greet command
greet says hello to $NAME (default "world"): try `wctr run -e NAME=you greet`.
greet says hello to $NAME (default "world"): try `wctr run -e NAME=you greet`.
hello command
hello is the smallest container: it greets and exits 0.
hello is the smallest container: it greets and exits 0.
lab command
lab is the playground's toolbox image: small commands that show what a container sees and does, one subcommand each.
lab is the playground's toolbox image: small commands that show what a container sees and does, one subcommand each.
mnist command
Command mnist is a WASI container that reads a handwritten digit: it embeds MNIST-12 (ONNX model zoo, MIT) and a 28x28 picture, runs the model through wasi-nn, and prints the digit with its probability.
Command mnist is a WASI container that reads a handwritten digit: it embeds MNIST-12 (ONNX model zoo, MIT) and a 28x28 picture, runs the model through wasi-nn, and prints the digit with its probability.
internal
class
Package class is the playground's "Join class": the engine worker joins a relay class with a join code the learner typed, puts a "class" network on the engine (network.Manager.AddDriver), keeps the session token in the engine's state store (IndexedDB) so a reload rejoins without the code, and leaves on request (the token is deleted, the network removed).
Package class is the playground's "Join class": the engine worker joins a relay class with a join code the learner typed, puts a "class" network on the engine (network.Manager.AddDriver), keeps the session token in the engine's state store (IndexedDB) so a reload rejoins without the code, and leaves on request (the token is deleted, the network removed).
console
Package console is the playground's terminal behind its adapter: the engine's line editor (cli/lineedit) and renderer, wired to a session that runs the submitted lines.
Package console is the playground's terminal behind its adapter: the engine's line editor (cli/lineedit) and renderer, wired to a session that runs the submitted lines.
idbstore
Package idbstore is an image.Backend over the browser's IndexedDB, so pulled images survive a page reload.
Package idbstore is an image.Backend over the browser's IndexedDB, so pulled images survive a page reload.
mirror
Package mirror writes the playground's image registry as static files: a read-only OCI distribution /v2 tree that GitHub Pages can serve, so the page pulls same-origin (ghcr.io sends no CORS headers, and the page's CSP allows connections to its own origin only).
Package mirror writes the playground's image registry as static files: a read-only OCI distribution /v2 tree that GitHub Pages can serve, so the page pulls same-origin (ghcr.io sends no CORS headers, and the page's CSP allows connections to its own origin only).
session
Package session turns terminal lines into wctr commands run against an engine, and makes what they print safe for the terminal: escape sequences filtered (termfilter), output rate bounded.
Package session turns terminal lines into wctr commands run against an engine, and makes what they print safe for the terminal: escape sequences filtered (termfilter), output rate bounded.
site
Package site assembles the playground's static site, serves it for tests, and checks a deployed copy against a build.
Package site assembles the playground's static site, serves it for tests, and checks a deployed copy against a build.
termfilter
Package termfilter makes untrusted output safe to hand to a terminal emulator (xterm.js in the playground).
Package termfilter makes untrusted output safe to hand to a terminal emulator (xterm.js in the playground).
thirdparty
Package thirdparty fetches the page's third-party scripts from the npm registry, checks them against the registry's published integrity hash, and writes the files the page serves itself (its CSP allows no other origin).
Package thirdparty fetches the page's third-party scripts from the npm registry, checks them against the registry's published integrity hash, and writes the files the page serves itself (its CSP allows no other origin).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL