Wado

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:

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:

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.

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:

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.

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:

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.

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:

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:

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

Dependencies

Not In Scope

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)

M1 — #[cm(..., linearity=…)]

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)

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

M4 — Type patterns and Eq

Out of MVP: Inspect / Display host imports; the serde-is-an-error rule.

M5 — WebIDL frontend, minimum slice

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

M7 — Server-side rendering under wado run / wado serve

M8 — Host glue and browser E2E

M9 — Callbacks

After M9

The roadmap ends at M9. What remains, and the order it is taken in, is tracked in package-web/TODO.md.