Skip to content

About

ARMv8A emulator that runs in a web browser via Web Assembly

Resources

Stars

18 stars

Watchers

0 watching

Forks

Repository files navigation

ARM64JS SDK

npm CI

Arm64js is a WebAssembly-based arm64 emulator running in a web browser and allowing you to boot and control a real Linux (Alpine).

This SDK helps to: create a VM, set it up and interact with it.

The SDK is distributed as an npm package and integrated with the arm64js CDN.

VM creation lifecycle

1. Boot a base image

Similarly to Docker, you need to select a base image to boot the initial state of your VM. At the moment, only base images provided by arm64js can be used as they require some customization to work "quicker" when emulated in a web browser. The list of images and their versions is at arm64js.com/images. Booting the image creates a VM instance:

import { Arm64JS } from 'arm64js';
const vm = await Arm64JS.boot('alpine');

2. Set up the VM

You would likely want to install some extra packages or copy files into your VM so that it can do something useful. For managing packages, use apk as you would in the native Alpine. To execute a command (any command pretty much), the vm.exec method is used:

const { output, exitCode } = await vm.exec('apk add --no-cache curl');

It returns raw output and exitCode so that you can verify the result.

The VM does not have access to the Internet except for the apk registry, unless you run an internet gateway for it (see Networking). To give it your own data, you can use one of the two options:

  • vm.writeFile: copies the specified file into the VM. Changing or deleting the file happens only inside the VM. The file is saved and loaded with the snapshot. Uses VM memory to copy the file, thus is intended mainly for smaller (up to 100 MiB) files.
  • vm.mount: read-only, direct mount of the data blob. Doesn't use any VM memory. Not stored in the snapshot. To unmount use vm.unmount().

See example below:

await vm.writeFile('/root/data.csv', file); // creates a physical file in the VM
await vm.mount({ 'model.bin': bigFile }, '/data'); // read-only, directly mounted blob, does not copy anything into the VM so it can be a large file

To read any file from the VM, use readFile:

const result = await vm.readFile('/root/result.txt'); // a copy out, as a Blob

3. Make a snapshot and save it locally

Once you are done with the VM setup, you would likely want to save it so that you don't need to do the same setup again. For that, make a snapshot of the VM. Snapshots are stored in the OPFS.

const snap = await vm.snapshot({ name: 'alpine with curl' });
console.log(snap.id); // this is the id we'd need to store to load the snapshot later

To load from the snapshot, specify its ID when creating the VM:

const again = await Arm64JS.boot(snap.id);

Where the VM runs

The VM needs SharedArrayBuffer which browsers only give to cross-origin-isolated pages. If the Cross-Origin-Opener-Policy (COOP) header is not specified or not sufficient, the SDK will load the VM inside an iframe on the cdn.arm64js.com domain.

The following decision process is being used:

Your page What happens
sends Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp the VM runs inline, in your page's own workers. Works in all modern browsers.
sends no COOP headers (likely) the VM runs in a hidden frame served from the arm64js CDN. Works in Chrome and Edge 137+.
no COOP header and a browser that doesn't support the iframe fallback (Firefox, Safari) boot() rejects with unsupported-browser.

Call Arm64JS.mode() to see which mode is being used.

Setting the headers

To run the VM inline, send these two headers with the page that runs the VM:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

We recommend setting them only on the pages that run the VM, not on the whole site, because they change how the browser treats a page:

  • files from other domains (scripts, images, fonts, iframes) load only if their server allows it with a Cross-Origin-Resource-Policy or CORS header (arm64js CDN already does).
  • popups the page opens lose their link to it, which can break for example sign-in or payment popups.

Here's an example of how to send them with nginx:

location = /playground.html {
    add_header Cross-Origin-Opener-Policy same-origin;
    add_header Cross-Origin-Embedder-Policy require-corp;
}

To check the setup, run await Arm64JS.mode(), it should return 'inline'.

Content-Security-Policy

If your page sends a Content-Security-Policy and you would like to use the iframe-based solution, you need to allow the arm64js CDN as an origin: script-src https://cdn.arm64js.com, connect-src https://cdn.arm64js.com, worker-src blob: (the VM's workers are started through a blob), and frame-src https://cdn.arm64js.com for the iframe itself.

Install

npm install arm64js

API

Arm64JS.boot(image | snapshotId, { vcpus?, gateway?, onProgress? }) → Vm

A VM can be created either from a base image or your own local snapshot.

  • image: name of an image on the CDN (all images): 'alpine' is the newest version of it, 'alpine:2' a fixed one, 'alpine@sha256:<hash>' an exact snapshot.
  • snapshotId: a snapshot id (snap.id, 64 hex characters) loads one of your own snapshots made previously.
  • 'demoshell:org/project:tag' loads a public Demoshell VM, if its owner's plan allows running it on other sites (paid or open source). The VM resumes as it was saved: one saved with an app running full-screen has that app on the console, so drive it with vm.write() rather than exec. Requires engine 0.11 or newer.

Additionally, you can specify an onProgress callback to track VM loading phases and the number of virtual CPUs the VM will have (more is not always better).

gateway: { url, token? } sends the VM's internet traffic through a gateway you run, see Internet gateway.

onProgress gets one argument, a BootProgress object:

interface BootProgress {
  stage: 'resolve' | 'manifest' | 'start' | 'memory' | 'run' | 'done';
  done?: number;
  total?: number;
}
  • the stages arrive in the following order: resolve → manifest → start → memory → run → done.
  • done and total are optional, they are filled in during the memory stage when the VM data chunks load (the slowers part of the boot for larger VMs).

Listener: vm.onOutput(cb) → unsubscribe

The raw bytes the guest prints on its console, including escape sequences.

const decoder = new TextDecoder(); // The output comes as bytes (Uint8Array), TextDecoder turns them into text.
const stopOutput = vm.onOutput((bytes, info) => {
  if (info.exec) return; // skip commands that we call ourselves
  console.log(decoder.decode(bytes, { stream: true }));
});

// to stop listening:
stopOutput();

vm.onExit(cb) → unsubscribe

The VM stopped (halted or faulted). Every later call on the VM rejects with vm-exited. If the hidden frame running the VM stops answering, reason is 'lost' and later calls reject with vm-lost.

If the VM has already stopped, the callback is called right away. It is not called for vm.dispose().

const stopExit = vm.onExit(({ reason }) => console.log(`VM stopped: ${reason}`));

// later, to stop listening:
stopExit();

vm.exec(command, { timeoutMs? }) → { output, exitCode, truncated }

Runs command in the guest's shell (sh), and returns what it printed and its exit status. command can be one command or several, one per line. The default timeout is two minutes.

It works by typing the command into the guest's serial console and reading the bytes back. There are a few limits:

  • stdout and stderr are merged into one stream.
  • Each line of the command must be under 4000 bytes.
  • output is capped at 1 MiB, and past that the head is dropped and truncated is true. Write to a file and read it back if you need more.
  • A timeout does its best to terminate the command (sends Ctrl-C to the VM's console), but that does not guarantee that the command will stop -- a program that ignores Ctrl-C (like vim) will keep running.

Whatever the command does runs as root in the guest.

Streaming the output

For longer running commands it can be useful to see output as it's being generated rather waiting for command to finish. To do that you can setup vm.onOutput before running vm.exec:

const decoder = new TextDecoder();
const stop = vm.onOutput((bytes, info) => {
  if (!info.exec) return; // keep only what exec prints
  console.log(decoder.decode(bytes));
});

try {
  const { exitCode } = await vm.exec('apk add --no-cache php');
} finally {
  stop();
}

vm.write(data)

Send data directly into the VM console, lower level than vm.exec, allows sending any keyboard sequences including key presses like Ctrl-C ('\x03') or Enter ('\r').

vm.resize(cols, rows)

Set the terminal console size. Mainly useful if you are writing your own terminal implementation.

If you want to use a terminal with your VM, it's easier to connect xterm (see "Terminal" section below).

vm.writeFile(path, data, { mode?, timeoutMs? })

Copies data (a Blob or File, bytes, or a string) into the VM as the file path (an absolute path). An existing file is replaced, a missing folder is created.

  • mode sets the permission bits, e.g. 0o755 for a script.
  • timeoutMs will terminate the operation if it doesn't complete within the specified time (for example, if the VM is very busy). Default is two minutes plus one second per MiB.

The copy is kept in VM memory, so it has to fit there (alpine for example has 1 GiB). If you need to use a larger file, use vm.mount instead.

Here's an example how you can load a file from the URL in to the VM:

const file = await fetch('./test.txt');
const fileBlob = await file.blob();
await vm.writeFile('/tmp/test.txt', fileBlob);
const { output, exitCode } = await vm.exec('cat /tmp/test.txt');
console.log(output);

Files become part of the VM, so when a snapshot is taken those files will be present in the snapshot.

vm.readFile(path, { maxBytes?, timeoutMs? }) → Blob

Copies a file out of the VM. The copy is kept in VM memory, so the file should fit there (default 1 GiB). If it doesn't, or the path is not a file but a directory, or doesn't exist, the read is refused with read-failed.

Here's an example of how a file can be downloaded from the VM:

const blob = await vm.readFile('/root/report.pdf');
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'report.pdf';
a.click();

vm.mount(files, path) → { path, unmount() }

Mounts files in read-only mode into the VM. Files are not copied, so they do not consume memory. path is created if missing. files is a record of names to Blobs ({ 'data.bin': blob }), or a list of Files (input.files or dataTransfer.files).

A path that is already mounted is refused with invalid-input: unmount it first, and wait for that to finish.

Mounts are not part of the VM and are not saved in snapshots. After you boot a snapshot, mount again.

vm.unmount(path)

Undoes a mount. If nothing is mounted at path it does nothing. While a program in the VM still uses the files, it rejects with unmount-failed.

vm.snapshot({ name?, meta? }) → SnapshotInfo

Saves the VM in browser storage (OPFS) and keeps it running. Use meta to store any extra info you'd like to add to the snapshot (any JSON up to 16 KiB); it is returned in snapshots.list().

For example to save current state and load it when page refreshes:

// load from snapshot if previously saved or alpine otherwise
const snapshotId = localStorage.get('snapshotId');
await Arm64JS.boot(snapshotId ? snapshotId : 'alpine');

// do something to the VM, then save the snapshot again
const newSnapshot = vm.snapshot();
localStorage.set('snapshotId', newSnapshot.id);

Creating snapshot for a larger VM may take some time, you can track progress using the onProgress callback:

const snapshot = await vm.snapshot({
  name: 'my snapshot',
  onProgress: (done, total) => console.log(`${Math.round((done / total) * 100)}%`),
});

vm.listen(port, onConnection) → { port, close() }

Creates a TCP server emulation on the webpage side "listening" to a specific port where VM can connect to. The application from inside the VM can open a TCP connection to host.internal:<port> and talk to it as it would to a normal TCP server.

Until the page "listens" on a port, connections to it are refused.

const listener = await vm.listen(9000, async (conn) => {
  for await (const chunk of conn) {
    // chunk is a Uint8Array with the bytes the program sent
    await conn.write(chunk); // send bytes (or a string) back
  }
  await conn.end(); // no more data from the page
});

await vm.exec('echo hello | nc host.internal 9000');

// to stop taking new connections (open ones keep going):
await listener.close();

onConnection is a callback that returns the TCP Connection.

A Connection has:

  • readable and writable: standard web streams of Uint8Array. The connection itself is also async-iterable.
  • write(data): sends bytes or a string. It resolves once the data fits in the 1 MiB send window, so awaiting each write keeps memory bounded.
  • end(): tells the VM "the page has nothing more to send" without cutting off what the VM sends back. In TCP terms it is a half close, the same as shutdown(SHUT_WR).
  • close(): ends the connection both ways. What the page already wrote still reaches the VM, then it reads end-of-file. Anything the VM sends after that is dropped. closed is a promise that resolves when the connection is over.

Both directions have flow control: if the page reads slowly, the VM's writes slow down, and the other way around.

Listeners and connections end when the VM stops or is disposed. They are not part of snapshots: after booting one, call listen again.

vm.serveHttp(port, handler, { maxBodyBytes? }) → { port, close() }

An HTTP server on top of vm.listen. Each request from the VM becomes a standard Request, and the Response your handler returns goes back. A response body is streamed as it is produced, so server-sent events work. Each connection carries one request.

For example, a page that tells the VM the time and echoes what it posts:

await vm.serveHttp(8080, async (req) => {
  if (req.method === 'POST') {
    return new Response(`you sent: ${await req.text()}\n`);
  }
  return new Response(`${new Date().toISOString()}\n`);
});

await vm.exec('wget -qO- http://host.internal:8080/');
// → { output: '2026-10-16T19:03:11.123Z', exitCode: 0, … }

await vm.exec("wget -qO- --post-data='hello' http://host.internal:8080/");
// → { output: 'you sent: hello', exitCode: 0, … }

Some details:

  • The request body is read in full before the handler runs. Bodies over maxBodyBytes (default 64 MiB) get a 413 without being read.
  • request.url is always http://host.internal:<port>/<path>. What the VM puts in the Host header is in request.headers. It does not change the URL, so passing the request on with fetch(request) cannot be pointed somewhere else by the VM.
  • A response without Content-Length is sent chunked, so a body that fails halfway reaches the VM as an incomplete response rather than a short one. If the VM disconnects, the response body is cancelled, so whatever produces it can stop.

vm.dispose()

Stops the VM and frees its workers.

Arm64JS.snapshots.list() / .get(id) / .remove(id, { force? })

Lists stored snapshots, newest first: { id, name, created, base, engine, vcpus, sizeBytes, meta }. remove is refused while a VM runs from that snapshot, unless force is specified.

Arm64JS.storage.status()

Returns an object describing the current storage situation: { available, reason?, persistent?, quota?, usage?, snapshots, poolBytes }.

Storage can be unavailable when:

  1. a page is not a secure context
  2. a browser without OPFS
  3. in frame mode when the browser blocks third-party storage (for example in a private window).

When storage is not available, snapshot() rejects with storage-unavailable.

Arm64JS.engine() → '0.4'

The engine version the SDK has been built with. A VM booted from a snapshot can run on another version (based on the version that was running when the snapshot was made). Use vm.engine to check which.

Arm64JS.shutdown()

Disposes every VM and, in frame mode, the frames.

Errors

Everything rejects with an Arm64JSError carrying a code: unsupported-browser, protocol-mismatch, engine-mismatch, image-not-found, boot-failed, exec-timeout, exec-failed, vm-exited, vm-lost, storage-unavailable, quota, snapshot-failed, snapshot-not-found, snapshot-in-use, snapshot-corrupt, invalid-input, share-unavailable, mount-failed, unmount-failed, write-failed, read-failed, listen-failed.

Terminal (optional)

A terminal is based on xterm.js and is not included by default; you need to install and wire it separately.

npm install @xterm/xterm @xterm/addon-fit
import { attachTerminal } from 'arm64js/terminal';
import '@xterm/xterm/css/xterm.css';

const term = attachTerminal(vm, document.getElementById('term'));
// term.xterm is the xterm instance; term.dispose() removes it and leaves the VM running.

It shows the VM console, sends what is typed to the VM, and sizes itself to the element (pass { fit: false } to keep a fixed size, and { xterm: { … } } for xterm's own options). vm.exec runs on the same console but stays out of the terminal; keys pressed while a vm.exec command is running are sent once it finishes.

Networking

The VM has an emulated network card, DNS, and can install Alpine packages with apk through the CDN's package mirror. It has no general internet access unless you give it a gateway.

The name host.internal is the page itself: see vm.listen and vm.serveHttp.

Internet gateway

A browser cannot open TCP connections, so a VM reaches the internet through a gateway: a small program that takes the VM's connections over a WebSocket and opens them from the machine it runs on. Download arm64js-gateway for Linux or macOS from arm64js.com, start it, and pass its address when booting:

./arm64js-gateway --ws 127.0.0.1:4434
const vm = await Arm64JS.boot('alpine', { gateway: { url: 'ws://127.0.0.1:4434' } });
await vm.exec('wget -qO- https://example.com');

By default the gateway relays every connection from any page that reaches it, to any address, including the machine it runs on and its local network. To narrow that down:

  • --allow-host registry.npmjs.org (repeatable, *.npmjs.org for a whole domain) relays only TCP connections to those names. A name that resolves to a private or local address is still refused; give the IP itself to allow an address. UDP and ping are then not relayed. Names that share an address with an allowed name on a large CDN can still be reached, because the gateway sees addresses, not the site inside the encrypted connection.
  • --allow-origin https://app.example.com (repeatable) serves only web pages from that origin. It does not stop other programs, which can send any origin, so a gateway other machines can reach needs --token. In frame mode the VM runs in the CDN's frame, so the origin the gateway sees is https://cdn.arm64js.com: use --token instead.
  • --token <secret> serves only clients that pass the same token in the boot option: { gateway: { url, token } }. Anyone who can read your page can read the token too. A client the gateway refuses gets no connection at all, so the VM boots without internet access.

Things to know:

  • Chrome asks the user to allow access to apps on their device the first time a page connects to a gateway on the local machine or network. For that, the page has to be served over https (or from localhost, which is not asked). A gateway on a public address never triggers the prompt.
  • A gateway on another machine needs wss://: browsers block a plain ws:// connection from an https page to anything but the local machine. Put it behind a TLS proxy such as Caddy.
  • DNS does not go through the gateway: it is resolved over HTTPS by the browser (Cloudflare's resolver). apk packages come from the CDN's mirror and use the gateway only when that download fails.
  • Needs engine 0.9 or newer. A snapshot saved on an older engine resumes on that engine, and booting it with gateway fails with engine-mismatch rather than starting a VM without internet access.

Development

npm install
npm test
npm run build

src/version.ts and protocol/dist/ are generated. npm install creates them; run npm run stamp again after changing the version in package.json.

protocol/ is the @arm64js/protocol package, which the engine uses too. To change it, bump its version and publish it (tag protocol-v<version>), update it in the engine and release the engine, then release this package.

License

MIT

About

ARMv8A emulator that runs in a web browser via Web Assembly

Resources

Stars

18 stars

Watchers

0 watching

Forks

Contributors

Languages