Embedding in a web page
Run Warm64 in a web worker, draw on a canvas, keep disks in OPFS and enable multiple cores with cross-origin isolation.
Warm64 runs in any modern browser. For a responsive page, run it in a web worker and let the page handle the canvas, the keyboard and the pointer.
Why a worker
- Speed. On a page's main thread, browsers compile WebAssembly modules larger than 4 KB only asynchronously, which slows the JIT's warm-up. In a worker it compiles them at once.
- Responsiveness. The machine runs in slices on the thread it's given. In a worker, the page never waits for it.
- Storage. The Origin Private File System's synchronous handles, which disks that persist need, exist only in workers.
Files to serve
Serve the package's wasm/ directory and its dist/ scripts together, as the package lays them out: Warm64 starts its own workers from files next to its scripts. browserWEMUHost({ base }) fetches the core from ${base}wasm/${kind}.wasm.
The worker
import { WEMU, browserWEMUHost } from "/node_modules/warm64/dist/index.js";
let vm;
self.onmessage = async ({ data }) => {
if (data.type === "start") {
vm = await WEMU.launch(data.args, {
...browserWEMUHost({ base: "/node_modules/warm64/" }),
display: data.canvas ? { canvas: data.canvas } : undefined,
});
vm.serial[0].onData((bytes) => self.postMessage({ type: "output", bytes }));
await vm.qmp("cont");
} else if (data.type === "input") {
vm.serial[0].write(data.text);
} else if (data.type === "qmp") {
self.postMessage({ type: "reply", id: data.id, reply: await vm.qmpJson(data.json) });
}
};The page
const worker = new Worker("worker.js", { type: "module" });
const canvas = document.querySelector("canvas").transferControlToOffscreen();
worker.postMessage({
type: "start",
canvas,
args: "-m 1G -S -kernel /images/vmlinuz-virt -initrd /images/initramfs-virt -append console=ttyAMA0 " +
"-device virtio-gpu-gl-pci -device virtio-keyboard-pci",
}, [canvas]);
const decoder = new TextDecoder();
worker.onmessage = ({ data }) => {
if (data.type === "output") terminal.write(decoder.decode(data.bytes, { stream: true }));
};The canvas moves to the worker with transferControlToOffscreen(), and Warm64 passes it on to its GPU worker, which draws on it with WebGPU or WebGL2.
The command line starts the machine paused with -S, so the worker can attach its console listener before any output; cont then starts it.
Keyboard and pointer
Forward the page's events to the worker, and turn them into QMP there. The pointer's position is 0 to 32767 across the canvas:
canvas.addEventListener("pointermove", (e) => {
const x = Math.round((e.offsetX / canvas.clientWidth) * 32767);
const y = Math.round((e.offsetY / canvas.clientHeight) * 32767);
worker.postMessage({ type: "qmp", id: 0, json: JSON.stringify({
execute: "input-send-event",
arguments: { events: [{ type: "abs", data: { axis: "x", value: x } }, { type: "abs", data: { axis: "y", value: y } }] },
}) });
});Keys go the same way with input-send-event and { type: "key", data: { down, key: { type: "qcode", data } } }, mapping the browser's KeyboardEvent.code to QEMU's key names.
Cross-origin isolation
More than one core, and the fastest GPU module, need SharedArrayBuffer, which browsers give only to cross-origin-isolated pages. Serve the page with:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpCheck it from script with self.crossOriginIsolated. Without it, a single core still runs, and the GPU falls back to its single-threaded module.
Disks that persist
The stock browser host reads each disk whole and keeps the guest's writes in memory. To keep them, open the disks in OPFS from the worker, as shown in openDisk.
Networking
Browsers can't open TCP connections, so the guest's go through a relay. See Networking in a browser.
Background tabs
A hidden tab gets far less CPU time, and the guest's clocks keep time with the wall clock, so a machine in a background tab runs much slower. Keep the page visible while a guest boots.