WEP: The Web Interface for Wado
Status: Implemented. This WEP is the proposal and the initial implementation.
Further work on package-web is tracked in
package-web/TODO.md.
Context
Wado targets WebAssembly and WASI, but the web browser remains the most important deployment platform for Wasm. Browser APIs (DOM, Fetch, Canvas, WebSocket, etc.) are specified in WebIDL — a language-neutral interface description format maintained by W3C. To use these APIs from Wado, we need a tool that generates type-safe Wado bindings from WebIDL definitions.
Prior Art: wasm-bindgen / web-sys
Rust's wasm-bindgen ecosystem has proven that WebIDL-to-language binding generation works. However, it has well-documented ergonomic problems:
Type casting boilerplate — JavaScript's prototype inheritance maps to Rust's Deref chain, requiring explicit dyn_into() / dyn_ref() casts:
// web-sys: get an input element and read its value
let el = document.get_element_by_id("search").unwrap();
let input: HtmlInputElement = el.dyn_into().unwrap(); // can panic
let value = input.value();
Untyped promises — All promises return JsValue, losing type information:
// web-sys: fetch returns untyped JsValue
let resp_value = JsFuture::from(window.fetch_with_str(&url)).await?;
let resp: Response = resp_value.dyn_into()?; // manual cast
let text = JsFuture::from(resp.text()?).await?;
let s: String = text.as_string().unwrap(); // another manual cast
Ugly overload naming — WebIDL method overloads produce suffixed names:
// web-sys: fetch has 4 generated variants
window.fetch_with_str(&url);
window.fetch_with_str_and_init(&url, &opts);
window.fetch_with_request(&req);
window.fetch_with_request_and_init(&req, &opts);
Closure boilerplate — Event handlers require Closure::wrap + .forget():
// web-sys: adding an event listener
let closure = Closure::wrap(Box::new(move |event: Event| {
// ...
}) as Box<dyn FnMut(Event)>);
element.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref())?;
closure.forget(); // leak memory to prevent drop
Wado's Advantages
Wado eliminates several root causes of these pain points:
- GC-based memory — Closures are first-class values managed by Wasm GC. No
Closure::wrapand no.forget()in user code; registration and the host trampoline are the compiler's job (see Callbacks). - Effect system — Side effects are tracked in function signatures (
with Dom,with Fetch), making it clear which functions touch browser APIs. - Colorless async — Wado uses Wasm Stack Switching for async.
Promise<T>maps toFuture<T>without viralasync/awaitcoloring. - Resource types — Wado's
resourcekeyword is a natural fit for opaque browser object handles.
Decision
Source Data
wado-from-idl uses the @webref/idl npm package as its source of truth. This package provides curated, validated WebIDL definitions scraped from W3C and WHATWG specifications, updated weekly. All definitions are guaranteed to parse and be internally consistent.
parseAll() (powered by webidl2) returns parsed AST nodes. scripts/webidl/snapshot.mjs writes one slice of that AST to package-web/idl/dom.webidl.json: the interfaces the script lists, their partials, their included mixins, and the typedefs their members name. The file is vendored, so generation needs no network. To widen the slice, edit the list and run mise run update-webidl-snapshot.
Tool Implementation
wado-from-idl (renamed from wado-from-wit) already provides a type-safe IR (WadoModule, WadoResource, WadoStruct, WadoEffect, etc.) and a WadoCodeGenerator that emits clean Wado source. The concepts map directly: WIT interfaces and WebIDL interfaces both produce resources, structs, enums, and effects.
The crate reads the snapshot alongside the WIT parser, sharing the IR and codegen layers (wado-from-idl/src/webidl.rs; mise run update-package-web):
wado-from-idl --webidl package-web/idl/dom.webidl.json --output-dir package-web/src
Pipeline:
@webref/idl → snapshot.mjs (webidl2 AST, JSON) → webidl.rs → IR (WadoModule: resources, effects) → WadoCodeGenerator → package-web/src/dom.wado
The bindings are a package, not the stdlib
The bindings ship as package-web, published as wado-lang:web, not as a
bundled web: namespace. A program imports them as any other package:
use { Dom, Element } from "wado-lang:web";. Everything built from one WebIDL
snapshot changes together, so it lives together: the bindings, the DOM a
server-side program runs against (SurfaceDom), and the browser glue. The glue
tags each handle with the class numbers the bindings declare, so the two must
come from one generation run. The web platform also moves at the pace of its
specs, not the compiler's.
The CM package is the package itself: wado-lang:web/node,
wado-lang:web/element, one interface per WebIDL interface. That is what the
component imports and what a host implements. The web platform is one package,
not one per spec: WebIDL interface names are unique across every spec, so they
never collide in one CM package, and the specs cross-reference one another too
densely to split (Window alone draws on HTML, CSSOM View, Fetch and Web
Storage). A wider slice widens package-web, never adds a package beside it.
The compiler and the CLI know nothing of it. A wado run, wado test or
wado serve host defines every import it does not provide as a trap, whatever
its namespace, so a program importing the bindings instantiates anywhere.
package-web will publish the WIT of wado-lang:web, but does not yet: the
hosts today are the generated glue and an effect handler, and neither reads WIT.
The WIT path remains unchanged (--wit-dir). Both frontends produce the same IR, so codegen improvements benefit both WASI and Web API bindings.
The frontend emits every member whose types the slice has. One it cannot type is skipped and named on stderr, as Element.attributes: NamedNodeMap is outside the slice. Nothing is approximated. A slice must be closed: a parent outside it is an error, so widening the slice means adding the whole chain. A test regenerates the module and fails when the committed file is stale.
Unified Attribute: #[cm("...")]
Web bindings use the grammar WASI bindings already use — namespace:package/interface[@version]#member. The web package carries no version, and each Wado resource gets its own interface (the universal-receiver lowering in Resource Inheritance):
#[cm("wado-lang:web/element", linearity = "unrestricted")]
pub resource Element extends Node {
#[cm("wado-lang:web/element#get-attribute")]
#[cm_params("self", "qualified-name")]
fn get_attribute(&self, qualified_name: String) -> Option<String>;
#[cm("wado-lang:web/element#id")]
#[cm_params("self")]
fn id(&self) -> String;
#[cm("wado-lang:web/element#set-id")]
#[cm_params("self", "value")]
fn set_id(&self, value: String);
}
The member is a bare name, unlike the [constructor] / [method] / [static] prefixes a WASI binding carries: those name a CM resource, and an unrestricted resource's interface declares none — the receiver is the universal handle, passed as an ordinary first parameter. A component whose interface exports a [method]x.y name it declares no resource for fails validation.
linearity = "unrestricted" makes the handle a copyable value (see Resource Inheritance). No getter / setter sub-attributes are needed: wado-from-idl generates the JS glue, so a WebIDL attribute is emitted as an ordinary getter/setter method pair, and a WebIDL constructor as a bare new.
The examples further down illustrate shape, not syntax: they use a shorthand path (#[cm("wado-lang:web#Element")]), leave linearity = "unrestricted" implicit, and mark WebIDL attributes with getter / setter. Read the declaration above for the form the generator emits and the compiler accepts — extends requires the linearity on both sides.
Language Extension: resource extends
WebIDL interfaces form single-inheritance hierarchies (e.g., EventTarget → Node → Element → HTMLElement → HTMLInputElement). Wasm-bindgen handles this with #[wasm_bindgen(extends = Node, extends = EventTarget)] attributes that generate Deref chains — a well-known Rust anti-pattern.
wado-from-idl introduces resource extends as a first-class Wado language feature:
#[cm("wado-lang:web#EventTarget")]
pub resource EventTarget { ... }
#[cm("wado-lang:web#Node")]
pub resource Node extends EventTarget { ... }
#[cm("wado-lang:web#Element")]
pub resource Element extends Node { ... }
#[cm("wado-lang:web#HTMLElement")]
pub resource HtmlElement extends Element { ... }
#[cm("wado-lang:web#HTMLInputElement")]
pub resource HtmlInputElement extends HtmlElement { ... }
Semantics:
- Single inheritance only — matches WebIDL and JS prototype chains.
- Upcast is implicit —
&HtmlInputElementis usable where&Elementis expected. The compiler inserts the conversion automatically. - Narrowing is a pattern —
if let el: Element = node { … }re-types the handle when the host confirms it, and takes the other branch when it does not (Resource Inheritance). The host performs theinstanceofcheck. - Method delegation — parent methods are callable on children without explicit upcast.
element.text_content()callsNode.textContenttransparently. - CM compilation — every extends-participating type collapses to one universal handle at the boundary (the Component Model has no inheritance concept); only the methods stay namespaced per Wado type. The
extendsrelationship is Wado-side only.
This applies only to resource (opaque handle types), not to struct (value types). Value types remain non-inheritable — inheritance is about handle polymorphism, not data layout.
WebIDL → Wado Type Mapping
Primitive Types
| WebIDL | Wado |
|---|---|
boolean |
bool |
byte / octet |
i8 / u8 |
short / unsigned short |
i16 / u16 |
long / unsigned long |
i32 / u32 |
long long / unsigned long long |
i64 / u64 |
float / unrestricted float |
f32 |
double / unrestricted double |
f64 |
DOMString / USVString / ByteString |
String |
undefined |
() |
Compound Types
| WebIDL | Wado | Example |
|---|---|---|
T? (nullable) |
Option<T> |
Element? → Option<Element> |
sequence<T> |
List<T> |
sequence<Node> → List<Node> |
FrozenArray<T> |
List<T> |
Immutable in JS, same Wado type |
Promise<T> |
Future<T> |
Promise<Response> → Future<Response> |
record<K, V> |
TreeMap<K, V> |
|
(A or B) (union) |
variant |
See union section |
any |
JsValue |
Opaque escape hatch |
Structural Types
| WebIDL | Wado | Pattern |
|---|---|---|
interface |
resource |
Opaque handle with methods |
interface X : Y |
resource X extends Y |
Single inheritance |
interface mixin |
trait |
Shared behavior across resources |
dictionary |
struct |
Data with field defaults |
enum |
enum |
String enum → Wado enum |
callback |
fn mut(...) with Dom |
Closure type |
typedef |
type |
Newtype alias |
Callbacks
A WebIDL callback, or a callback interface of one operation, maps to a Wado
closure type: EventListener is fn mut(Event) with Dom. The Component Model
has no function values, so a closure never crosses the boundary
(Closure Implementation Internals).
The bindings take wasm-bindgen's shape, adapted: a u32 key crosses, and the
closure stays in the guest.
- A closure parameter of a
#[cm]import crosses as au32. The call site registers the closure incore:rt's registry, which hands one closure one key, soremoveEventListenerreceives the functionaddEventListenerdid. - The host calls the closure back through an export of
wado:callback/callback, one per argument shape:call-handle(key: u32, a0: f64)servesfn mut(Event),call-f64servesfn mut(f64), andcallservesfn mut(). A handle crosses as anf64but is another type in the guest, so its export is its own. The export is lifted synchronously and returns nothing.wado witlists the interface among the world's exports. - The glue turns a key into one JavaScript function, which calls the export.
- The body runs as a fresh task. It may perform an effect a world import backs,
as
Domis, but a handler installed withwith … doaround the registration is out of extent. - A callback takes only scalars and handles, and returns nothing. The compiler
rejects any other closure on a
#[cm]declaration, andwado-from-idlskips a member whose callback does not fit. - Every argument of a callback is required. A closure takes each parameter on
every call, so
wado-from-idlskips a callback with an optional or variadic argument, which the page may leave out. - A nullable or optional callback is a required closure. The DOM ignores a null
listener, so leaving the call out says the same.
Option<fn>does not cross.
The CM lets the host call back into the component during an import call
(Concurrency.md, "Reentrance"), and wasmtime does. An event-loop callback is
legal too, even while run is suspended.
Known gaps:
dispatch_eventruns the listeners during the import call. jco 1.30.0, whichscripts/jcopins, then fails the export the listener reentered (Research: CM Pain Points).SurfaceDomruns the listener.- Every closure type a declared import takes gets an export, whether the program passes one or not.
- The glue serves one component instance: the last one instantiated receives every callback.
SurfaceDomneither removes a listener nor ignores one added twice. Both compare two closures by identity, which onlycore:*can.- Each mention of a named function makes a new closure, so passing one function
to
addEventListenerand then toremoveEventListenercrosses two keys, and the listener stays. - A callback that runs while
runis suspended inside awith … dobody dispatches to that body's handlers: handlers are installed per instance, not per task.
Release is the open end: v1 keeps the registry entry for the life of the instance, the honest equivalent of .forget(). The way out is a callback resource whose drop releases it (WebAssembly/component-model#278 — the shape a Wado closure already has), imported rather than guest-exported, since every type an import refers to must itself be imported. The registry survives either way: a resource rep is an i32, not a GC reference. CM first-class function values retire the trampoline; nothing before that does. Research: CM Pain Points records what the spec lacks here.
Browser Glue
wado-from-idl writes package-web/glue/dom.js from the same lowering as
dom.wado, so each import has one shim and the two files change together. A
shim looks up the object behind each handle it receives and uses the member: it
reads or writes an attribute, or calls an operation or a constructor. An object
it returns becomes a handle.
- One table maps each object to its handle, so an object always crosses as the
same handle. The handle's class is the nearest class the slice declares,
found once with
instanceof, deepest class first. A class the environment does not define is passed over, and an object of no class the slice declares is aTypeError. - A
Nonecrosses asundefined. WebIDL reads that asnullfor a nullable type and as a missing argument for an optional one. Anullthe DOM returns becomesNone. - The
[Global]interface isglobalThis, and its attributes are read from it. - The released-jco transpile maps each
wado-lang:web/<interface>import to the glue module exporting that interface. The glue an output imports is copied beside it asweb-<module>.js. - The transpile appends a call to the output that hands its
wado:callback/callbackexport to the glue's$connect. The output reads the glue's exports as it loads, so the glue cannot import the output back.
mise run test-web-glue runs example/web-browser on Node against
jsdom, a standard DOM without a browser
engine. It checks the transpile, the glue and the DOM calls without the weight
of a browser. mise run web-browser-demo serves the same program to a browser,
through wado serve and the static file server example/static_server.wado.
Known gaps:
- CI runs no browser, so a difference between jsdom and a browser's DOM goes unseen there.
- The table holds every object it has handed out for the life of the page, as
the callback registry does. A handle has no drop to release it by, since an
unrestricted resource crosses as a plain
f64.
Exceptions
A DOM exception traps. No operation returns a Result for one.
WebIDL declares no exceptions. Which operation throws, and what, is written in
each spec's algorithm prose, so the generator has nothing to build a Result
from. The only machine-readable lists belong to engines, Gecko's [Throws] and
Blink's [RaisesException], and each records one implementation rather than
the standard.
- In a browser, the glue does nothing. The exception unwinds the Wado frames, which catch nothing, and becomes a trap where it leaves the component.
- A handler traps where the standard throws, and its message names the
DOMException:HierarchyRequestError: ….SurfaceDomfollows this.
The Component Model's error handling:
Canonical ABI, canon lift
(call_and_trap_on_throw),
Explainer, Canonical ABI,
Explainer, JS API
(result).
Known gap: a program cannot recover from a DOM exception. A call that throws ends the export call it runs in, so code that expects a failure, such as a selector or a name taken from input, has to validate the value before it calls.
Effect Design
Web API calls are side effects. wado-from-idl groups them into per-specification effects, following the same pattern as WASI effects (Stdout, FileSystem, etc.). All of them are declared in the one generated module and imported from wado-lang:web:
// DOM access
pub interface Dom {
fn window() -> Window;
fn document() -> Document;
}
// Network requests
pub interface Fetch {
fn fetch(input: String, init: Option<RequestInit>) -> Future<Response>;
}
// Canvas rendering
pub interface Canvas2D { ... }
// WebSocket connections
pub interface WebSocket { ... }
// localStorage / sessionStorage
pub interface Storage { ... }
User code declares which effects it needs:
fn app() with (Dom, Fetch) {
let doc = Dom::document();
let data = Fetch::fetch("/api/data", null);
}
Method Overload Strategy
WebIDL allows method overloading (same name, different signatures) and optional parameters with default values. The intended shape resolves these using default arguments:
// WebIDL: optional parameter with default
Node cloneNode(optional boolean deep = false);
// WebIDL: optional parameter without default
undefined fillText(DOMString text, double x, double y, optional double maxWidth);
// WebIDL: nullable callback with optional third argument
undefined addEventListener(DOMString type, EventListener? callback,
optional (AddEventListenerOptions or boolean) options);
// Generated Wado: optional params with known defaults use the actual type
fn clone_node(&self, deep: bool = false) -> Node;
// Optional params without a WebIDL default use Option<T> with = null
fn fill_text(&self, text: String, x: f64, y: f64, max_width: Option<f64> = null);
// A nullable callback is required (see Callbacks); optional params get defaults
fn add_event_listener(&self, type: String, callback: fn mut(Event) with Dom,
options: Option<AddEventListenerOptions> = null);
Default arguments are desugared at the call site with zero overhead — see WEP: Default Arguments.
For true type overloads (different types at the same position), wado-from-idl generates a variant:
variant CanvasImageSourceOrPath {
Source(CanvasImageSource),
Path(Path2d),
}
An optional parameter takes its WebIDL default where the Wado type can spell it: clone_node(subtree: bool = false). Any other is an Option<T> = null: leaving it None is leaving the argument out, so the browser applies the WebIDL default. A parameter keeps its default only when every parameter after it has one.
The frontend falls short of that shape in three ways, each a known gap:
- An optional the slice cannot type is dropped, along with every argument after it. A variadic argument is a
List<T>, which the glue spreads into the call:document.write(["<p>", "hi"]). One the slice cannot type takes its member with it. - A union collapses to its one typable constituent only where the guest supplies the value.
set_inner_htmltakes(TrustedHTML or DOMString)asString: the slice cannot build aTrustedHTML, so nothing is lost on the way in. On the way out the host could return the dropped constituent, so the member is skipped — there is noinner_htmlgetter, onlyget_html. Anundefinedconstituent is the nullability marker, not a dropped one. Two typable constituents want the variant above, which is not built, so the member is skipped. - Overloads are not merged. A name is emitted only when exactly one of its overloads lowers, so
Window.alert()/alert(message)is skipped.
Concrete Examples
EventTarget → Node → Element (Inheritance Chain)
WebIDL:
interface EventTarget {
constructor();
undefined addEventListener(DOMString type, EventListener? callback,
optional (AddEventListenerOptions or boolean) options);
undefined removeEventListener(DOMString type, EventListener? callback,
optional (EventListenerOptions or boolean) options);
boolean dispatchEvent(Event event);
};
interface Node : EventTarget {
readonly attribute unsigned short nodeType;
readonly attribute DOMString nodeName;
readonly attribute Node? parentNode;
readonly attribute NodeList childNodes;
attribute DOMString? textContent;
Node appendChild(Node node);
Node removeChild(Node child);
Node cloneNode(optional boolean deep = false);
};
interface Element : Node {
readonly attribute DOMString tagName;
attribute DOMString id;
attribute DOMString className;
DOMString? getAttribute(DOMString qualifiedName);
undefined setAttribute(DOMString qualifiedName, DOMString value);
Element? querySelector(DOMString selectors);
NodeList querySelectorAll(DOMString selectors);
};
Generated Wado:
#![generated]
pub struct EventListenerOptions {
pub capture: bool = false,
}
pub struct AddEventListenerOptions {
pub capture: bool = false,
pub passive: Option<bool> = null,
pub once: bool = false,
pub signal: Option<AbortSignal> = null,
}
#[cm("wado-lang:web#EventTarget")]
pub resource EventTarget {
#[cm("wado-lang:web#EventTarget.new")]
fn new() -> EventTarget;
#[cm("wado-lang:web#EventTarget.addEventListener")]
fn add_event_listener(&self, type: String, callback: fn mut(Event) with Dom,
options: AddEventListenerOptions = AddEventListenerOptions {});
#[cm("wado-lang:web#EventTarget.removeEventListener")]
fn remove_event_listener(&self, type: String, callback: fn mut(Event) with Dom,
options: EventListenerOptions = EventListenerOptions {});
#[cm("wado-lang:web#EventTarget.dispatchEvent")]
fn dispatch_event(&self, event: Event) -> bool;
}
#[cm("wado-lang:web#Node")]
pub resource Node extends EventTarget {
#[cm("wado-lang:web#Node.nodeType", getter)]
fn node_type(&self) -> u16;
#[cm("wado-lang:web#Node.nodeName", getter)]
fn node_name(&self) -> String;
#[cm("wado-lang:web#Node.parentNode", getter)]
fn parent_node(&self) -> Option<Node>;
#[cm("wado-lang:web#Node.childNodes", getter)]
fn child_nodes(&self) -> NodeList;
#[cm("wado-lang:web#Node.textContent", getter)]
fn text_content(&self) -> Option<String>;
#[cm("wado-lang:web#Node.textContent", setter)]
fn set_text_content(&self, value: Option<String>);
#[cm("wado-lang:web#Node.appendChild")]
fn append_child(&self, node: Node) -> Node;
#[cm("wado-lang:web#Node.removeChild")]
fn remove_child(&self, child: Node) -> Node;
#[cm("wado-lang:web#Node.cloneNode")]
fn clone_node(&self, deep: bool = false) -> Node;
}
#[cm("wado-lang:web#Element")]
pub resource Element extends Node {
#[cm("wado-lang:web#Element.tagName", getter)]
fn tag_name(&self) -> String;
#[cm("wado-lang:web#Element.id", getter)]
fn id(&self) -> String;
#[cm("wado-lang:web#Element.id", setter)]
fn set_id(&self, value: String);
#[cm("wado-lang:web#Element.className", getter)]
fn class_name(&self) -> String;
#[cm("wado-lang:web#Element.className", setter)]
fn set_class_name(&self, value: String);
#[cm("wado-lang:web#Element.getAttribute")]
fn get_attribute(&self, qualified_name: String) -> Option<String>;
#[cm("wado-lang:web#Element.setAttribute")]
fn set_attribute(&self, qualified_name: String, value: String);
#[cm("wado-lang:web#Element.querySelector")]
fn query_selector(&self, selectors: String) -> Option<Element>;
#[cm("wado-lang:web#Element.querySelectorAll")]
fn query_selector_all(&self, selectors: String) -> NodeList;
}
Key design points:
- Dictionary inheritance is flattened (
AddEventListenerOptionsincludescapturefromEventListenerOptions). Dictionary fields use struct field defaults. (AddEventListenerOptions or boolean)union is simplified toOption<AddEventListenerOptions>. Thebooleanshorthand ({capture: true}) is a JS convenience that the host runtime can handle.callback interface EventListener { undefined handleEvent(Event event); }becomesfn mut(Event) with Domdirectly; the compiler does the registration (see Callbacks).optional boolean deep = falsebecomesdeep: bool = false— default arguments are desugared at the call site with zero overhead.
Document and the Dom Effect
#[cm("wado-lang:web")]
pub interface Dom {
fn window() -> Window;
fn document() -> Document;
}
#[cm("wado-lang:web#Document")]
pub resource Document extends Node {
#[cm("wado-lang:web#Document.new")]
fn new() -> Document;
#[cm("wado-lang:web#Document.getElementById")]
fn get_element_by_id(&self, element_id: String) -> Option<Element>;
#[cm("wado-lang:web#Document.createElement")]
fn create_element(&self, local_name: String, options: Option<String> = null) -> Element;
#[cm("wado-lang:web#Document.createTextNode")]
fn create_text_node(&self, data: String) -> Text;
#[cm("wado-lang:web#Document.querySelector")]
fn query_selector(&self, selectors: String) -> Option<Element>;
#[cm("wado-lang:web#Document.querySelectorAll")]
fn query_selector_all(&self, selectors: String) -> NodeList;
#[cm("wado-lang:web#Document.body", getter)]
fn body(&self) -> Option<HtmlElement>;
#[cm("wado-lang:web#Document.head", getter)]
fn head(&self) -> Option<HtmlHeadElement>;
}
User code:
use { Dom, Document, Element } from "wado-lang:web";
fn setup_page() with Dom {
let doc = Dom::document();
let el = doc.create_element("div");
el.set_id("app");
el.set_text_content(Option::Some("Hello, Wado!"));
if let Some(body) = doc.body() {
body.append_child(el); // append_child from Node, available via extends
}
}
Compared to web-sys:
// web-sys: 6 unwrap/expect calls, 2 type casts
let window = web_sys::window().expect("no window");
let document = window.document().expect("no document");
let body = document.body().expect("no body");
let el = document.create_element("div").expect("create_element failed");
el.set_id("app");
el.set_text_content(Some("Hello, Wado!"));
body.append_child(&el).expect("append_child failed");
HTMLInputElement (Deep Inheritance)
Five-level chain: EventTarget → Node → Element → HTMLElement → HTMLInputElement.
#[cm("wado-lang:web#HTMLElement")]
pub resource HtmlElement extends Element {
#[cm("wado-lang:web#HTMLElement.title", getter)]
fn title(&self) -> String;
#[cm("wado-lang:web#HTMLElement.title", setter)]
fn set_title(&self, value: String);
#[cm("wado-lang:web#HTMLElement.hidden", getter)]
fn hidden(&self) -> Option<String>;
#[cm("wado-lang:web#HTMLElement.hidden", setter)]
fn set_hidden(&self, value: Option<String>);
#[cm("wado-lang:web#HTMLElement.click")]
fn click(&self);
#[cm("wado-lang:web#HTMLElement.innerText", getter)]
fn inner_text(&self) -> String;
}
#[cm("wado-lang:web#HTMLInputElement")]
pub resource HtmlInputElement extends HtmlElement {
#[cm("wado-lang:web#HTMLInputElement.value", getter)]
fn value(&self) -> String;
#[cm("wado-lang:web#HTMLInputElement.value", setter)]
fn set_value(&self, value: String);
#[cm("wado-lang:web#HTMLInputElement.type", getter)]
fn type_(&self) -> String;
#[cm("wado-lang:web#HTMLInputElement.type", setter)]
fn set_type(&self, value: String);
#[cm("wado-lang:web#HTMLInputElement.checked", getter)]
fn checked(&self) -> bool;
#[cm("wado-lang:web#HTMLInputElement.checked", setter)]
fn set_checked(&self, value: bool);
#[cm("wado-lang:web#HTMLInputElement.disabled", getter)]
fn disabled(&self) -> bool;
#[cm("wado-lang:web#HTMLInputElement.disabled", setter)]
fn set_disabled(&self, value: bool);
#[cm("wado-lang:web#HTMLInputElement.focus")]
fn focus(&self);
}
User code — thanks to resource extends, all ancestor methods are directly accessible:
use { Dom, Document, HtmlInputElement } from "wado-lang:web";
fn setup_form() with Dom {
let doc = Dom::document();
if let Some(el) = doc.query_selector("#username") {
if let input: HtmlInputElement = el {
// HtmlInputElement's own method
let val = input.value();
// Element's method (2 levels up) — no cast needed
input.set_attribute("placeholder", "Enter username");
// Node's method (3 levels up) — no cast needed
let parent = input.parent_node();
// EventTarget's method (4 levels up) — no cast needed
input.add_event_listener("input", Option::Some(|e: Event| {
// No Closure::wrap, no .forget() — the compiler registers it
log_stdout(`input changed`);
})); // options defaults to null
}
}
}
Compared to web-sys:
// web-sys: requires explicit dyn_into at every level crossing
let el = document.query_selector("#username")?.unwrap();
let input: HtmlInputElement = el.dyn_into()?; // explicit cast
input.set_attribute("placeholder", "Enter username")?;
let parent = input.parent_node(); // works via Deref to Node
// Event listener requires Closure::wrap boilerplate...
Events (Typed Event Handling)
#[cm("wado-lang:web#Event")]
pub resource Event {
#[cm("wado-lang:web#Event.type", getter)]
fn type_(&self) -> String;
#[cm("wado-lang:web#Event.target", getter)]
fn target(&self) -> Option<EventTarget>;
#[cm("wado-lang:web#Event.preventDefault")]
fn prevent_default(&self);
#[cm("wado-lang:web#Event.stopPropagation")]
fn stop_propagation(&self);
}
#[cm("wado-lang:web#MouseEvent")]
pub resource MouseEvent extends Event {
#[cm("wado-lang:web#MouseEvent.clientX", getter)]
fn client_x(&self) -> i32;
#[cm("wado-lang:web#MouseEvent.clientY", getter)]
fn client_y(&self) -> i32;
#[cm("wado-lang:web#MouseEvent.button", getter)]
fn button(&self) -> i16;
}
#[cm("wado-lang:web#KeyboardEvent")]
pub resource KeyboardEvent extends Event {
#[cm("wado-lang:web#KeyboardEvent.key", getter)]
fn key(&self) -> String;
#[cm("wado-lang:web#KeyboardEvent.code", getter)]
fn code(&self) -> String;
#[cm("wado-lang:web#KeyboardEvent.ctrlKey", getter)]
fn ctrl_key(&self) -> bool;
#[cm("wado-lang:web#KeyboardEvent.shiftKey", getter)]
fn shift_key(&self) -> bool;
}
User code — a typed event handler, narrowed by a type pattern:
use { Dom, KeyboardEvent } from "wado-lang:web";
fn setup_search() with Dom {
let doc = Dom::document();
if let Some(input) = doc.query_selector("#search") {
input.add_event_listener("keydown", |e: Event| {
if let ke: KeyboardEvent = e {
if ke.key() == "Enter" {
e.prevent_default();
// ... perform search
}
}
}); // options defaults to null
}
}
A handler serving several event types is one flat match. The final _ arm is
required, because the hierarchy is open:
fn describe(e: Event) -> String {
return match e {
ke: KeyboardEvent => `key ${ke.key()}`,
me: MouseEvent => `click ${me.client_x()},${me.client_y()}`,
_ => e.type_(),
};
}
Fetch (Typed Async)
WebIDL:
dictionary RequestInit {
ByteString method;
HeadersInit headers;
BodyInit? body;
RequestMode mode;
RequestCredentials credentials;
};
interface Response {
readonly attribute boolean ok;
readonly attribute unsigned short status;
readonly attribute ByteString statusText;
readonly attribute Headers headers;
Promise<USVString> text();
Promise<any> json();
Response clone();
};
Generated Wado:
pub struct RequestInit {
pub method: Option<String> = null,
pub headers: Option<Headers> = null,
pub body: Option<String> = null,
pub mode: Option<RequestMode> = null,
pub credentials: Option<RequestCredentials> = null,
}
pub enum RequestMode {
Cors,
NoCors,
SameOrigin,
Navigate,
}
pub enum RequestCredentials {
Omit,
SameOrigin,
Include,
}
#[cm("wado-lang:web#Response")]
pub resource Response {
#[cm("wado-lang:web#Response.ok", getter)]
fn ok(&self) -> bool;
#[cm("wado-lang:web#Response.status", getter)]
fn status(&self) -> u16;
#[cm("wado-lang:web#Response.statusText", getter)]
fn status_text(&self) -> String;
#[cm("wado-lang:web#Response.headers", getter)]
fn headers(&self) -> Headers;
#[cm("wado-lang:web#Response.text")]
fn text(&self) -> Future<String>;
#[cm("wado-lang:web#Response.json")]
fn json(&self) -> Future<JsValue>;
#[cm("wado-lang:web#Response.clone")]
fn clone(&self) -> Response;
}
#[cm("wado-lang:web")]
pub interface Fetch {
#[cm("wado-lang:web#fetch")]
fn fetch(input: String, init: RequestInit = RequestInit {}) -> Future<Response>;
}
User code:
use { Fetch, Response, RequestInit, RequestMode } from "wado-lang:web";
fn load_data(url: String) with Fetch {
let resp = Fetch::fetch(url).read(); // init defaults to RequestInit {}
if let Some(r) = resp {
if r.ok() {
let body = r.text().read();
if let Some(text) = body {
log_stdout(`Got: ${text}`);
}
}
}
}
fn post_json(url: String, body: String) with Fetch {
let init: RequestInit = {
method: Option::Some("POST"),
body: Option::Some(body),
mode: Option::Some(RequestMode::Cors),
};
let resp = Fetch::fetch(url, init).read();
}
Compared to web-sys:
// web-sys: 12+ lines, untyped JsValue everywhere
let mut opts = RequestInit::new();
opts.method("POST");
opts.body(Some(&JsValue::from_str(&body)));
let request = Request::new_with_str_and_init(&url, &opts)?;
let window = web_sys::window().unwrap();
let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?;
let resp: Response = resp_value.dyn_into()?;
let text_value = JsFuture::from(resp.text()?).await?;
let text: String = text_value.as_string().unwrap();
Canvas2D (Drawing API)
#[cm("wado-lang:web#HTMLCanvasElement")]
pub resource HtmlCanvasElement extends HtmlElement {
#[cm("wado-lang:web#HTMLCanvasElement.width", getter)]
fn width(&self) -> u32;
#[cm("wado-lang:web#HTMLCanvasElement.width", setter)]
fn set_width(&self, value: u32);
#[cm("wado-lang:web#HTMLCanvasElement.height", getter)]
fn height(&self) -> u32;
#[cm("wado-lang:web#HTMLCanvasElement.height", setter)]
fn set_height(&self, value: u32);
#[cm("wado-lang:web#HTMLCanvasElement.getContext")]
fn get_context(&self, context_id: String) -> Option<CanvasRenderingContext2d>;
}
#[cm("wado-lang:web#CanvasRenderingContext2D")]
pub resource CanvasRenderingContext2d {
fn save(&self);
fn restore(&self);
fn fill_rect(&self, x: f64, y: f64, w: f64, h: f64);
fn stroke_rect(&self, x: f64, y: f64, w: f64, h: f64);
fn clear_rect(&self, x: f64, y: f64, w: f64, h: f64);
fn begin_path(&self);
fn close_path(&self);
fn move_to(&self, x: f64, y: f64);
fn line_to(&self, x: f64, y: f64);
fn arc(&self, x: f64, y: f64, radius: f64,
start_angle: f64, end_angle: f64, anticlockwise: bool = false);
fn fill(&self);
fn stroke(&self);
#[cm("wado-lang:web#CanvasRenderingContext2D.fillStyle", getter)]
fn fill_style(&self) -> String;
#[cm("wado-lang:web#CanvasRenderingContext2D.fillStyle", setter)]
fn set_fill_style(&self, value: String);
#[cm("wado-lang:web#CanvasRenderingContext2D.strokeStyle", getter)]
fn stroke_style(&self) -> String;
#[cm("wado-lang:web#CanvasRenderingContext2D.strokeStyle", setter)]
fn set_stroke_style(&self, value: String);
#[cm("wado-lang:web#CanvasRenderingContext2D.lineWidth", getter)]
fn line_width(&self) -> f64;
#[cm("wado-lang:web#CanvasRenderingContext2D.lineWidth", setter)]
fn set_line_width(&self, value: f64);
fn fill_text(&self, text: String, x: f64, y: f64, max_width: Option<f64> = null);
#[cm("wado-lang:web#CanvasRenderingContext2D.font", getter)]
fn font(&self) -> String;
#[cm("wado-lang:web#CanvasRenderingContext2D.font", setter)]
fn set_font(&self, value: String);
}
User code:
use { Dom, HtmlCanvasElement, CanvasRenderingContext2d } from "wado-lang:web";
fn draw() with Dom {
let doc = Dom::document();
if let Some(canvas) = doc.query_selector("#myCanvas") {
if let c: HtmlCanvasElement = canvas {
if let Some(ctx) = c.get_context("2d") {
ctx.set_fill_style("red");
ctx.fill_rect(10.0, 10.0, 100.0, 50.0);
ctx.begin_path();
ctx.arc(75.0, 75.0, 50.0, 0.0, f64::PI * 2.0); // anticlockwise defaults to false
ctx.set_fill_style("blue");
ctx.fill();
ctx.set_font("24px serif");
ctx.fill_text("Hello Wado!", 50.0, 150.0); // max_width defaults to null
}
}
}
}
Mixins (Trait Composition)
WebIDL:
interface mixin ParentNode {
readonly attribute HTMLCollection children;
Element? querySelector(DOMString selectors);
NodeList querySelectorAll(DOMString selectors);
};
Document includes ParentNode;
Element includes ParentNode;
DocumentFragment includes ParentNode;
Generated Wado:
pub trait ParentNode {
fn children(&self) -> HtmlCollection;
fn query_selector(&self, selectors: String) -> Option<Element>;
fn query_selector_all(&self, selectors: String) -> NodeList;
}
impl ParentNode for Document { /* generated delegation to host */ }
impl ParentNode for Element { /* generated delegation to host */ }
impl ParentNode for DocumentFragment { /* generated delegation to host */ }
Mixins are the one place where Wado traits shine over resource extends: a mixin can be applied to unrelated types that don't share an inheritance chain.
Known gap: a mixin's members are folded into each interface that includes it, so Document and Element each declare their own query_selector. The trait form changes only the Wado declaration, not the CM surface. It waits on a consumer that needs the mixin as a bound.
Union Types (Variant Generation)
WebIDL unions become Wado variants:
// WebIDL
typedef (Node or DOMString) NodeOrString;
// Generated Wado
pub variant NodeOrString {
AsNode(Node),
AsString(String),
}
Common unions get readable names from their WebIDL typedef. Unnamed unions get auto-generated names from constituent types.
Output File Structure
package-web/ # wado-lang:web
├── idl/
│ └── dom.webidl.json # the snapshot dom.wado is generated from
├── glue/
│ └── dom.js # the browser glue, generated beside dom.wado
└── src/
├── lib.wado # the package entry, re-exporting the bindings
├── dom.wado # every resource of the slice, and the Dom effect
└── surface_dom.wado # SurfaceDom — the DOM's API with no browser engine, answering the Dom effect
One package, and one generated module for the whole slice. The WASI layout, a file per WIT interface, does not carry over: WebIDL specs reference each other both ways, so a per-spec split would import in a cycle. HTMLElement extends the DOM's Element, and that spec's Document returns an HTMLElement. A wider slice widens the snapshot, not the file count. The slice's [Global] interface yields the effect that hands out the first handle: Dom::window(), and an accessor for each read-only attribute of the global typed as another slice resource, such as Dom::document().
Comparison Summary
| Pain Point | web-sys (Rust) | Wado (wado-from-idl) |
|---|---|---|
| Type casting | el.dyn_into::<HtmlInputElement>()? |
if let input: HtmlInputElement = el |
| Closure boilerplate | Closure::wrap(Box::new(...)).forget() |
\|e: Event\| { ... } — the compiler registers it |
| Untyped promises | JsFuture → JsValue (untyped) |
Future<Response> (typed) |
| Overload naming | fetch_with_str_and_init() |
fetch(url) or fetch(url, init) |
| Feature flags | features = ["Element", "Node", ...] |
None needed |
| Inheritance | Deref chain (anti-pattern) |
resource extends (first-class) |
| Effect tracking | None (side effects implicit) | with Dom, with Fetch (explicit) |
Consequences
Language Changes Required
resource extendssyntax — new compiler feature for resource inheritance.#[cm("...")]attribute — Component Model bindings with namespace in the value string.- Type patterns (
p: T) on resources withextends, refutable whereTis a strict subtype. - Implicit upcast coercion for resource reference types.
Dependencies
@webref/idlnpm package as source of truth for WebIDL definitions.wado-from-idlcrate (shared IR and codegen with WIT frontend).- jco, which transpiles the component for a browser, served by the glue above.
Not In Scope
- Hand-tuned high-level APIs (e.g., ergonomic DOM builder DSL) — may come later as a separate library on top of the generated bindings.
- Web Components / Custom Elements support.
- Service Workers / Web Workers API design.
- Named parameters — a separate Wado language WEP.
Minimum Implementation Roadmap
The MVP is one vertical slice that runs in a browser: get an element by id, read and write its text, narrow it to HtmlInputElement, read its value. Everything off that path is deferred (last section), and each milestone lands compiling and running.
Premise: the handle is a value, not a CM resource
Every extends-participating type collapses to one CM type at the boundary: a copyable f64 holding a class and an index into a host-side table owned by the JS glue. It is deliberately not a CM resource, whose own / borrow handles carry the uniqueness and drop obligations a handle hierarchy cannot meet (Resource Inheritance). The cost is that a handle is never released (see Deferred), and CM-GC does not lift it — see that WEP's known gap on what a real externref would take.
M0 — Spec alignment (docs only)
- [x]
#[cm]for web bindings uses the existing CM path grammar (above) - [x] Narrowing is a type pattern, not a
downcastmethod (Resource Inheritance) - [x] v1 universal-handle representation recorded in Resource Inheritance
M1 — #[cm(..., linearity=…)]
- [x] Parse fields on
#[cm]: a path string followed bykey = "value"fields - [x] Validate the value (
"affine"|"unrestricted") and the placement (aresource, since linearity is a property of the handle type) - [x]
linearity = "unrestricted"makes the resource copyable and exempt from the affine analysis of Resource Ownership, and from the cleanup pass that would otherwise drop it
Out of MVP: making linearity= mandatory on every #[cm] resource, a stdlib-wide migration with no MVP payoff, and declaring it on the non-owning tokens that already have value semantics without it.
M2 — extends (language core)
- [x] Keyword, AST, parser; single parent, parent must be a
resource, cycles rejected - [x] Subtype relation and implicit upcast, carried by assignability's position: a value, a return and a
&Treferent admit a subtype;&mut T, containers and function types do not - [x] Method resolution over the chain, plus the corner cases that have a surface: override forbidden, trait-vs-inherited collision, statics do not inherit,
Selffixed at the declaring resource. Visibility judged at the declaring module is unimplemented — a resource method has no visibility of its own (Resource Inheritance) - [x] Linearity validation:
extendsrequireslinearity = "unrestricted"on both sides
Out of MVP: generic resources on either side of extends — no WebIDL interface needs one. Unbuilt for want of a consumer, not ruled out.
M3 — Universal-handle lowering
- [x] Unrestricted resource → the universal handle at the CM boundary; upcast and the inherited-method receiver are no-ops
- [x] One WIT interface per Wado type, the handle rendered the same in every position
- [x] Import synthesis for the interfaces a package declares, not the stdlib
- [x] E2E in
tests/integration/: a two-levelextendsprogram against a wasmtime host stub
M4 — Type patterns and Eq
- [x] Pattern ascription (
p: T) in the grammar, absorbing theletannotation slot so one construct serves both - [x] Refutability from the subtype relation: irrefutable where
S <: T, refutable whereTis a strict subtype, an error otherwise; genericTrejected - [x]
matchover type patterns:_required, and a dead arm shadowed by an earlier ancestor reported - [x]
#[cm(..., classes = "lo..=hi")]numbers eachextendstree; a refutable pattern lowers to a range compare on the handle and a branch - [x]
Eqauto-derived as bit equality on handles the host interns - [x] E2E in
tests/integration/: narrowing and==against the wasmtime host stub
Out of MVP: Inspect / Display host imports; the serde-is-an-error rule.
M5 — WebIDL frontend, minimum slice
- [x]
@webref/idlJSON ingestion alongside the WIT frontend, sharing the IR (wado-from-idl) - [x] IR and codegen:
extends,#[cm(..., linearity = "unrestricted", classes = …)]numbered in pre-order,#[cm_params] - [x] Vendor a pinned JSON snapshot of the slice so generation is deterministic in tests
- [x] Slice only:
EventTarget,Event,Node,Element,HTMLElement,HTMLInputElement,Document,Window, with every member whose types the slice expresses
Out of MVP: mixins as traits, union-to-variant, dictionaries, overload merging, enums, full spec coverage. Method Overload Strategy and Mixins say what stands in for each.
Premise: on a server, a handler is the DOM
No Wado host is a browser. wado test, wado run and wado serve define every import they do not provide as a trap, so a program instantiates wherever it runs, and a Wado effect handler answers each call it makes: with &mut dom do { app(); }. The program is the same one the browser runs; only its entry installs the handler. A call no handler answers traps when made, not at instantiation.
M6 — Handler-served DOM under wado test
- [x] The host fills every import it does not provide with a trap
- [x] The CM package is
wado-lang:web, the package's own name, and nothing in the compiler or the CLI names it - [x] A method inherited through
extendsdispatches to the handler of the resource declaring it - [x] Narrowing and
==read the handle alone, so a handler that mints tagged handles serves them too - [x] The bindings move from the stdlib to
package-web(wado-lang:web) - [x] A DOM without a browser engine in
package-web(SurfaceDom), enough for the MVP slice: a tree, text, lookup by id, an input's value
M7 — Server-side rendering under wado run / wado serve
- [x]
SurfaceDomserializes to HTML by the HTML standard:to_html()for the document, doctype included, andElement::get_htmlfor an element's children - [x]
example/web-ssr: one page, printed bywado runand served bywado serve
M8 — Host glue and browser E2E
- [x] Glue generator over the same IR: per-interface method shims over a handle table that interns each object and tags it with its nearest class in the slice
- [x] jco transpile with the web imports mapped to the glue (
scripts/jco,mise run jco-*) - [x] CI gate: Node run against jsdom, which checks the glue without a browser; manual browser check for the same program
- [x]
example/demo program and amisetask
M9 — Callbacks
- [x] A closure crosses a
#[cm]import as itsu32key, and the host calls it back throughwado:callback/callback - [x]
wado-from-idllowers acallbackand a one-operationcallback interface:add_event_listener,request_animation_frame,queue_microtask - [x] The glue calls a key back through the export, one function per key
- [x]
SurfaceDomholds listeners and dispatches an event through the capture path to its target - [x]
example/web-browsergreets again on eachinputevent, inSurfaceDomand on jsdom
After M9
The roadmap ends at M9. What remains, and the order it is taken in, is tracked
in package-web/TODO.md.
