If you could peer through a portal into a parallel universe, you might see an alternate timeline where the Web evolved in a very different way. No HTML, no CSS, and no JavaScript: a Web built from the ground up for applications, not documents.
We’ve been there before, sort of. Flash, Java Applets, Silverlight. But those technologies had some very serious shortcomings. (Let’s not get into that.)
All over the internet, you hear a common refrain from developers in opposite camps; those forced to develop for the Web, and those who refuse to: “Why is web development so damn complicated?” Well, what’s the alternative? I think that endless argument is perfectly illustrated by this interaction.
Are native apps really the way to go? What about shareability, universality, cross-platform compatibility, ease of access and ease of distribution? The hyperlink was the killer feature of the early Web, for a very good reason.
The fact is, the Web won. And let’s be real, there is a subset of the Web platform that is actually good.
As the modern Web has evolved away from simple websites (“documents”) towards rich, interactive and highly-connected experiences (“applications”), the technology has evolved to meet those new demands.
Enter WebAssembly: a way to run secure sandboxed code at near-native speeds in the browser and beyond. But this is not an introduction to WASM, you can read up on that in your own time. You’re here with us to explore the alternate universe of a Web built for applications.
Imagine running native-like interactive graphical applications that are sandboxed, with security through capabilities.
Allow us to introduce you to levo, which is not a browser, but a portal.
Full disclaimer: This was built over the holiday break, and in its current state it’s more of a proof of concept than a fully-fledged platform that will ultimately dethrone the browser as the undisputed ruler of the Web.
Portals will run your guest applications, written in any language, to allow a truly native desktop app experience that is shareable via URL.
The video above (press Play!) shows a “guest” application, being “hosted” by the portal.
The portal currently expects brotli-encoded wasm32-wasi
binaries that meets the relevant spec/host.wit
contract and
is served over the network on a URL.
The WIT (WebAssembly Interface Types) format is an interface
description language. We use it to define the contract between guest
(client app) and host (portal): the guest import
s
functionality from the host, and export
s a set of functions
that the host can call:
// spec/host.wit
package levo:portal;
interface my-imports {
variant mouse-button {
// The left mouse button.
left,
// The right mouse button.
right,
// The middle mouse button.
middle,
// Another mouse button with the associated number.
other(u16),
}
// ... other types
record position {
x: float32,
y: float32,
}
record size {
width: float32,
height: float32,
}
label: func(text: string, x: float32, y: float32, size: float32, color: string);
link: func(url: string, text: string, x: float32, y: float32, size: float32);
delta-seconds: func() -> float32;
key-just-pressed: func(key: key-code) -> bool;
key-pressed: func(key: key-code) -> bool;
key-just-released: func(key: key-code) -> bool;
mouse-button-just-pressed: func(btn: mouse-button) -> bool;
mouse-button-just-released: func(btn: mouse-button) -> bool;
mouse-button-pressed: func(btn: mouse-button) -> bool;
cursor-position: func() -> option<position>;
canvas-size: func() -> size;
// ... other functions
}
world my-world {
import my-imports;
export update: func();
export setup: func();
}
You can use wit-bindgen-cli
to generate bindings for spec/host.wit
for your chosen
language, or at least one that compiles to wasm32-wasi
and
is
supported by wit-bindgen
(sorry Haskellers).
wit-bindgen tiny-go ../../spec --out-dir=my-world
Or, if you write your client app in a better language, you can use the
wit-bindgen
crate to generate bindings with the
wit_bindgen::generate!()
macro.
From your client app, you export a setup()
function and
an update()
function, as defined in the exports of
spec/host.wit
. The example below is using Rust.
// src/lib.rs
use levo::portal::my_imports::*;
impl Guest for MyWorld {
fn setup() {
// canvas_size() is exposed by the host
let size = canvas_size();
let width = size.width;
let height = size.height;
let message = format!("Hello from Rust! ({width}x{height})");
// print() is exposed by the host
;
print(message)// thanks to WASI, and wasmtime providing an implementation of it,
// guests can use parts of their standard library
println!("Thank you WASI!");
}
fn update() {}
}
# Cargo.toml
[lib]
crate-type = ["cdylib"]
We can write the same app in Go:
// src/my-component.go
// bindings are in: my-world/
func (e HostImpl) Setup() {
// worldLevoPortalMyImportsCanvasSize() is exposed by the host
var width float32 = world.LevoPortalMyImportsCanvasSize().Width
var height float32 = world.LevoPortalMyImportsCanvasSize().Height
:= fmt.Sprintf("Hello from Go! (%dx%d)", width, height)
message // world.LevoPortalMyImportsPrint() is exposed by the host
.LevoPortalMyImportsPrint(message)
world// thanks to WASI, and wasmtime providing an implementation of it,
// guests can use parts of their standard library
.Printf("Thank you WASI!");
fmt}
func (e HostImpl) Update() {}
Compile your app as a C-compatible dynamic library to the target
wasm32-wasi
platform using your chosen language’s build
tools.
# Go
tinygo build -target=wasi -o main.wasm src/my-component.go
# Rust
cargo build --target wasm32-wasi --release
Once you’ve compiled your app, you can use wasm-tools
to
adapt your wasm binary to the WebAssembly Component Model.
wasm-tools component new ../../target/wasm32-wasi/release/rust_client_app.wasm \
-o my-component.wasm --adapt ../wasi_snapshot_preview1.reactor.wasm
Compress that final artifact using brotli
(which can be
done using the included brotli-encoder
tool).
cargo run --package brotli-encoder --release \
-- my-component.wasm "../../levo-server/public/rust.wasm"
Finally, serve the compressed artifact (which can be done using the
included levo-server
static file server).
cd levo-server
SERVER_CONFIG_FILE=./config.toml cargo r --release
# serves files in the levo-server/public directory
Run the portal
cargo run --release --package portal
and navigate to the location of the client app wasm file using the
portal’s address bar http://localhost:8080/rust.wasm
.
levo is currently built on top of:
*.wit
files to define the contracts between
the host portal and guest client applicationswasm-tools
and wit-bindgen
: tooling that
means we can make use of the aboveWASI
: the ability to interact with the system securely,
which means client apps can make use of their existing standard
librariesbevy
: a delightful game engine that builds on top of
the incredible Rust ecosystem to handle everything from input to
windowing to audio to GPU to rendering and much moreIf you look at the levo repo, you’ll find a few directories:
levo/ - you are here
spec/ - this contains the host.wit
interface, which is the
real heart of the project
portal/ - this contains the portal
(host) implementation
clients/ - this contains the same
guest application built in
3 languages: Rust, Go and C
brotli-encoder/ - this is a simple command
line tool to compress
the wasm binary using
brotli encoding
levo-server/ - this is a simple server that
serves the brotli-encoded
wasm client applications
from a directory on the
file system
The current structure is aspirational. We’re using cutting-edge technologies like WASM, WIT, and WASI, and we’ve structured this based on the assumption of guest applications being served over a network. We’re very much building on top of quicksand here, but we know these technologies are the future of the Web, however they ultimately compose.
The portal
app is the meat of the project, and is the
first prototype implementation of a “portal”.
It’s built with Bevy, which
will give us easy access to winit
, wgpu
and a
host of other amazing projects in the Rust ecosystem, parts of which we
plan to expose through capabilities.
In its current form, it has an address bar that accepts a URL
to the client app WASM file. After entering the URL and pressing
Enter, the portal connects to the server, downloads the brotli-encoded
wasm file, decodes it, and initializes the wasmtime
runtime
like so:
// Set up Wasmtime components
let mut config = Config::new();
.wasm_component_model(true).async_support(false);
configlet engine = Engine::new(&config)?;
let component = Component::new(&engine, decoded_input)?;
// Set up Wasmtime linker
let mut linker = Linker::new(&engine);
sync::add_to_linker(&mut linker)?;
let table = Table::new();
let wasi = WasiCtxBuilder::new().build();
// levo::portal::MyWorld is generated by the wasmtime::bindgen!() macro
MyWorld::add_to_linker(&mut linker, |state: &mut MyCtx| state)?;
// Set up Wasmtime store
let mut store = Store::new(
&engine,
{
MyCtx ,
table,
wasi: cc,
channel},
;
)let (bindings, _) = MyWorld::instantiate(&mut store, &component, &linker)?;
The current API and implementation for the portal is simple:
During those functions, the guest (client app) can use any portal
functions declared in the spec/host.wit
file and the host
(the portal) provides implementations for these interfaces.
For example,
After the guest update schedule, the host will call the corresponding
bevy_prototype_lyon
functions to construct the entities and
ultimately draw to the screen.
At the next frame, we despawn previous entities and start again. It’s an immediate mode style API.
wasmtime
supports the component
model and wasi:
WASI is designed with capability-based security principles, using the facilities provided by the Wasm component model. All access to external resources is provided by capabilities.
This allows us to run guest code without risking the security of the
host machine, while limiting access to some capabilities. As a proof of
concept for capabilities, we allow a guest app to read from a single
directory specified via the --allow-read
command-line flag.
Take a look at rust-test-read-file.
cargo run --release --package portal -- --allow-read "./public"
Given this file tree:
public/
hello.txt
private/
secret.txt
// from client app
let Ok(hello) = levo::portal::my_imports::read_file("hello.txt") else {
// this error will not print
"Failed to read public/hello.txt");
print(return;
};
// the contents of `public/hello.txt` will print
&String::from_utf8_lossy(&hello));
print(let Ok(secret) = levo::portal::my_imports::read_file("../private/secret.txt") else {
// this error will print
"Failed to read private/secret.txt");
print(return;
};
// the contents of `private/secret.txt` will not print
&String::from_utf8_lossy(&secret)); print(
If --allow-read
is omitted, neither of the files will be
read.
If --allow-read="./public"
, the contents of
./public/hello.txt
is printed successfully.
We want to be able to make and meet this promise: write a native app in your chosen language that is secure and massively distributable.
spec/host.wit
is the real heart of the project, and we
want to explore the design of the APIs exposed by the portal. There is a
tension between providing a high level API (the start of which we have
now) and a low level API for more granular and powerful control of the
host machine. We want to explore both, however, for now the focus is on
the high level API so we get those clear wins.
Our next steps:
Flesh out the high level API, both in the spec and in the portal implementation - being built on Bevy and exposing an immediate mode style API it’s already in a state where you could implement 2D HTML-canvas style games
As an option, we could repurpose the portal shell to run in non-network contexts,
Investigate and implement a low level API to provide access to,
or mirror, wgpu
, winit
, and so on, both in the
spec and in the portal implementation - this would enable
fn main()
style apps where the guest app owns the loop -
Bevy provides access to these ecosystems
Build out the “capabilities” architecture in the portal implementation; we will look to Deno and Tauri here for inspiration. For example, being able to provide restrict access to resources such as
Let’s take a step back and peer through the looking glass into a possible future.
HTML, CSS and JS are a thing of the past. There is a new standard. You choose to use Rust (lib.rs), C (my-component.c), C++, Java, Go (my-component.go) or some emerging future language to write native-like app experiences that can be quickly and easily shared, distributed and executed securely.
Perhaps one day the browsers will implement portals, or something like it, natively.
Until then, we’ll just have to build it ourselves.