Skip to content

WIT surface shapes: the web-embedding fast-path bet - mirror WebIDL on pass-through surfaces, resources not indices, streams not callbacks #15

Description

@lannbot

Provisional plan — recorded 2026-08-16 from the review of WebAssembly/component-model#686 (the web-embedding design sketch: components bind directly to WebIDL, imports still wired explicitly by the instantiator). Adopted as the working posture for WIT surface design; converted to a ruling only by the checkpoints at the end. Related: NOTES.md — Why components, App-frame sandboxing; issues #5, #6, #13; upstream tracking in lann/deltic#115.

Scope of relevance

686 (and the Firefox prototype behind it) matters to polymorph only as motivation for future-compatible API shapes. The bet, in one line:

Assume WIT interfaces that mirror WebIDL will eventually get engine-native fast paths; interfaces that don't, won't.

Not a dependency, not a timeline bet: deltic remains the mechanism, the linker remains the permission system, and nothing assumes the embedding ships on the target browser matrix. The sketch's own gaps (no attribute getters/setters, no callbacks, no dynamic union passing) mean event-driven DOM is not natively expressible yet regardless — the RPC/mutation UI architecture stands on its merits.

Two properties of the sketch make the bet safe to take:

  • No ambient authority. Every import resolves by lookup on an imports object the instantiator constructs; interposition (JS polyfill in place of a web API) is a stated goal. Permission-as-linker survives the native embedding unchanged: deny-by-unlink still fails at instantiation (unresolvable import → TypeError), stubs and async prompt-on-first-use imports keep working.
  • The import list stays the boundary even under wholesale handover. Passing a real interface object satisfies only the [method] imports the component declared; the WIT world remains the install-time consent surface. Audit weight shifts to return types and object scope, not method reachability.

Shape rules

For any surface that could ever be satisfied by handing over a platform object ("pass-through-able"):

  1. Mirror WebIDL names and signatures exactly. The sketch resolves [method] imports by camelCase(kebab-name) lookup on the interface object's prototype: resource element { query-selector: ... } can someday be satisfied by literally passing Element; a renamed method is JS-glue forever. Deviate from platform naming only where policy demands it, knowing each deviation pins that method to the wrapper path.
  2. Platform-mirror interfaces are pure subsets. One framework-specific method on a mirrored resource poisons wholesale handover (the extra import resolves to undefined on the real prototype → instantiation TypeError). Extensions live in sibling interfaces. Subsetting in the other direction is free: the embedding drops extra args and defaults missing ones, so mirrors don't chase optional-parameter growth.
  3. Stay inside the canonical type table. string/list/record (camelCase-able field names)/enum (labels map verbatim — no case conversion)/option for nullables/f64 for unrestricted doubles; top-level result<T, E> on throwing functions (maps to exceptions; deltic already aligned via lann/deltic#114). Avoid as API currency: dynamic unions, option<option<T>> (degrades to the generic variant case), error-context (unresolved upstream).
  4. Handles are resources — never integer indices or string IDs. Today they're deltic table entries; under native bindings they're host handles. Same unforgeability, zero migration. (Supersedes the earlier u32 asset-index idea: resource asset gives launder-by-construction in future-compatible form.)
  5. Events as stream<record>/future<T>, not callback imports. Callbacks are upstream open question 4; streams map to ReadableStream, futures to Promise, and record-shaped events are what the RPC topology wants anyway. Attributes (open question 6) get mechanical get-x/set-x pairs; accept a tooling-level rename if upstream lands a different convention.
  6. Glue-era optimizations stay out of the contract. Binary mutation encodings, shared-memory rings, batch-shaped APIs: their benefit evaporates under native bindings. Batch host-side (e.g. queue-and-flush per animation frame in the wrapper), never API-side.

The tension, and why it resolves cleanly here

The future-fast shape (DOM-mirror, per-call) is the present-slow shape — the Mozilla Dodrio experiment's 45% was precisely the cost of chatty per-op glue. But in this topology the component boundary is not the bottleneck: every mutation crosses postMessage into the UI frame regardless, and host-side per-frame batching hides per-call overhead at UI rates. A DOM-shaped polymorph:dom world costs little today.

The payoff is placement, not just speed: with native bindings, the same app artifact could instantiate in-frame against real, subtree-scoped elements — collapsing the two-hop RPC path. The existing design already pays for the safety: the sandbox origin's header CSP makes the frame network-dead, so confinement never depended on wrapper-side laundering; under in-frame placement the wrappers' remaining duties degrade to integrity/anti-spoofing, not exfiltration control. Same "one artifact, many placements" argument as headless execution, extended across the trust topology.

Scope split

The bet governs pass-through-able surfaces only: the DOM subset, canvas, streams/codec-shaped utilities. Policy surfaces are exempt and interposed by design, forever: proxied fetch (#7), data services (#8), groups (#9), and polymorph:webcrypto's deliberately reshaped capability API. Shape those for audit, batching, and consent granularity with no WebIDL-fidelity constraint. Keep the two categories in separate WIT packages so the distinction is legible in the surface itself.

Standing rule independent of upstream posture: app components are never loaded declaratively. The sketch defers ESM-integration (where browser-bound ambient authority could appear); polymorph instantiates explicitly regardless of what lands there.

Conversion checkpoints (provisional → ruling)

  • The first specified app-facing UI world (polymorph:dom or successor) is written under these rules, and the walking-skeleton UI spike measures per-call vs batched overhead across the two-hop path under deltic — confirming the "frame hop dominates" premise.
  • Upstream resolution of attribute getters/setters and callbacks either matches the conventions above or forces the (tooling-mechanical) rename migration — either outcome converts rule 5 from bet to fact.
  • ESM-integration's ambient-binding posture becomes concrete upstream; the declarative-loading rule gets restated against the real design.

Open sub-questions

  • Which surfaces beyond DOM/canvas are worth mirroring (TextEncoder/Decoder, CompressionStream, OffscreenCanvas contexts)?
  • How subtree scoping is expressed for a future in-frame placement (clamped root resource vs per-node grants).
  • Whether the DOM-mirror subset is versioned as its own package with an upstream-alignment changelog, given the no-@unstable posture.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions