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 itsactionand 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 trivialsend(string)/receive()transport maps 1:1 onto the webviewsendToHost/ipc-messagepair. 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-channelcontextBridgealso 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
capnweband review updates.
- A build step in an otherwise build-free package family;
- 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.