Docs

ADR-0003: Cap’n Web as the guest↔host RPC layer

Status: Active · Date: 2026-07-02

Context

The tranquil browser embeds pages in an Electron <webview> and needs two-way communication between those guest pages and the host renderer. The concrete trigger: we want an HTML page to register its own pane-control buttons and actions into the tab-bar chrome, instead of the host hardcoding them per file:// URL and reaching into the page via executeJavaScript.

Our only guest↔host primitive was raw Electron IPC — named string channels, structured-clone payloads, and no function passing. Every callback-shaped feature forced a hand-rolled mini-protocol: a correlation id, a second channel to route invocations, and a Map of handlers; “actions” ran by shipping strings the host executeJavaScript‘d into the page. Each future guest↔host feature would re-hand-roll this. The missing piece wasn’t a button API — it was an RPC layer.

A further constraint drives the design: the browser loads content over both file:// (trusted local mockups) and http(s):// (arbitrary remote), and the <webview> runs with nodeIntegration: on. Any RPC layer must assume hostile content and gate authority per origin.

Decision

Adopt Cap’n Web (capnweb, MIT, Cloudflare) as a core Tranquil building block — object-capability RPC where functions/objects pass by reference as stubs and both sides call each other symmetrically — packaged as the owned tranquil-rpc dev package. Authority is granted per-principal via a default-deny trust model: only file:// pages under a registered trusted root get the injected guest runtime and host capabilities; all http(s):// (and file:// outside roots) get no RPC surface at all. Pane controls is the first consumer.

Options considered

  • Option A (chosen): Cap’n Web (tranquil-rpc) — functions pass as stubs, so the guest hands the host its action and the host just calls it; the id-map + invoke channel disappear. Capabilities are the authority boundary (host.notify(…), host.paneControls.register(…)), which is the natural place to gate untrusted origins. Symmetric sessions; a trivial send(string)/receive() transport maps 1:1 onto the webview sendToHost/ipc-message pair. Downsides: needs a build step (capnweb is ESM → bundle to CJS host + IIFE guest, dist/ committed); a new dependency on a young library (2025) that must be version-pinned.
  • Option B: keep hand-rolling Electron IPC — no new dependency, no build step. Downside: every callback feature re-invents id-correlation + an invoke channel + a handler map, and host-effect actions keep running via string executeJavaScript. The named-channel contextBridge also grows without a per-origin authority boundary. Doesn’t solve the actual problem, only defers it.
  • Option C: a bespoke in-house RPC protocol — full control, no third-party risk. Downside: re-implements exactly what Cap’n Web already provides (stubs, promise pipelining, symmetric sessions, lifecycle/disposal) — high cost to build and maintain, and we’d own every bug. “Adopt, don’t invent.”

Consequences

  • Easier: guest↔host callbacks become ordinary method calls with function arguments; host effects are scoped capabilities, not new channels; authority is per-principal and revocable. The pane-controls hardcoded path (registerMockupControls + SCROLL_TOP_JS) is removed in favor of pages self-registering.
  • Harder / new risks:
    • A build step in an otherwise build-free package family; dist/ is committed and injected into pages, so bundle integrity is security-relevant.
    • A security-critical trust classifier (isTrusted) — a bug grants capabilities to hostile pages, so it is default-deny, unit-tested, and reviewed on every change.
    • Cap’n Web lifetime semantics bit us live: argument stubs are disposed when the call returns (retain with .dup()), and capability shape follows the factory’s return type (bare function → host.name(…); RpcTarget → namespace). Documented in the dev doc.
    • A new young dependency: pin capnweb and review updates.
  • Re-evaluation triggers: Cap’n Web is abandoned or breaks compatibility we can’t pin around; the trust model proves insufficient for a new content source; or the build/bundle cost outweighs the benefit for the set of consumers we end up with.

See also

  • Dev doc: Guest↔Host RPC — architecture, transport, trust model, capability/types registry, host + guest API.
  • tranquil-rpc/docs/SECURITY.md — the living security tracker for this surface.