The question this document answers: evaluateJs learned to await promises three different ways —
callAsyncJavaScript on iOS, an AsyncScript wrapper over the shared bridge on Android, and an
opt-in flag deciding whether either happens at all. What should it look like instead, and what is
worth taking from parkwoocheol/compose-webview,
which was reviewed as a reference for this design?
compose-webview is a hosting library: the app embeds its own first-party page and wants typed
RPC with it. Its bridge is page→native — window.AppBridge.call(method, data) returns a Promise,
native resolves it by callbackId, handlers register as register<T, R> suspend lambdas.
Vitre is an automation library. It drives third-party pages that will never call
window.vitre, and its primary direction is native→page. So the reference's public API — the
handler registry, BridgeSerializer, reified register<T,R> — solves a problem no Vitre
caller has, and none of it should be copied. Its emit() is actively wrong (it concatenates the
event name into JS unescaped — an injection our jsString already rules out), and its
CoroutineScope(Dispatchers.Main) lifecycle is looser than WebViewSerializer's model.
Four transport-level ideas are worth taking, and they map exactly onto the defects below:
- A dedicated correlation table. Request/response matching lives in a
callbackId → callbackmap, not in a general message stream that observers also read. BridgeInvocationContext. Every inbound message carriessourceOrigin,isMainFrame, and which transport delivered it — and handlers can gate on them. We receive the same facts from every platform today and discard them.addDocumentStartJavaScript(Android) as the origin-scoped place to install a runtime once, rather than re-wrapping per call.JavaScriptReplyProxyas a frame-bound reply channel — a reply reaches exactly the frame that called, without anevaluateJavascriptround-trip. Not needed for this redesign, but it is the right primitive if page→native RPC is ever built (see the page→native section below).
AndroidWebViewController registers the message listener with setOf("*") and discards
sourceOrigin and isMainFrame; iOS installs the bridge user script with
forMainFrameOnly = false and ignores WKScriptMessage.frameInfo. evaluateAndSettle then waits
on the shared inbox for any message whose payload says the right cid — and cids are a counter
starting at zero, trivially guessable. So a third-party iframe on a page a lane is driving can run:
window.vitre.postMessage(JSON.stringify({
id: "script:result#1", type: "script:result",
payload: { cid: 1, ok: true, value: "\"attacker's answer\"" }
}))and resolve a workflow's Extract with data of its choosing. For a library whose stated job is
driving third-party shops, that is the defect that matters most.
Honest limit: a script running in the main frame shares the JS context with our wrapper and can
shadow window.vitre.postMessage before the wrapper calls it, so nothing short of an isolated
content world makes main-frame results tamper-proof — a hostile main document can already lie in
its DOM, which is where the data comes from anyway. The boundary we can and should enforce is the
frame boundary: only the document we are driving may answer, and its subframes may not.
Script results travel through WebViewInbox — the same buffer that holds page traffic for
AwaitMessage and feeds bridge.messages, the "non-consuming firehose" documented as being for
hosts' debug panes. Consequences: every settle does an O(n) predicate scan (with a JSON decode per
candidate) over a deque holding unrelated traffic; internal script:result plumbing leaks into the
observer stream; and a stale result that nobody awaits any more sits in unread until the next
navigation clears it.
__wv_pending:<cid> is an ordinary string. A script that legitimately evaluates to that string is
indistinguishable from a pending promise, and the caller then waits out the full script timeout for
a settle that will never come.
iOS awaits with callAsyncJavaScript; Android awaits with AsyncScript over the bridge; both gate
on an awaitsPromises constructor flag that defaults to false — i.e. the default is the
behaviour where a promise silently serialises as {}. The flag's own KDoc recounts the silent
cross-platform divergence this already caused once. Lanes remember to pass true on both
platforms; the Compose hosts (VitreWebView.android.kt:34, VitreWebView.ios.kt:38) do
not, so a hosted page and a lane disagree about what evaluateJs means today.
Decision (owner-confirmed): awaitsPromises is deleted, not defaulted to true. The contract
becomes: evaluateJs returns the script's settled value, JSON-encoded. await on a plain value
is a no-op, so the synchronous case is unchanged in meaning; a promise stops being a special case
the caller has to have opted into.
Migration is small because the flag is young: the two pools drop the argument; the two Compose
hosts change behaviour — a hosted page's evaluateJs on a promise now returns the value instead of
{}. Nobody can plausibly depend on receiving {}; the KDoc on WebViewController.evaluateJs
gains a line saying promises are awaited and ScriptTimeoutException bounds the wait.
A new small object owned by each controller, in commonMain next to AsyncScript:
internal class ScriptResults {
fun expect(cid: Long): CompletableDeferred<SettledResult>
/** True if [raw] was a script result and was consumed. Never suspends. */
fun deliver(raw: String): Boolean
/** Fails every pending wait: the document that would have answered is gone. */
fun clear()
fun forget(cid: Long)
}The platform message callback offers every inbound message to ScriptResults.deliver first;
only messages it does not claim go on to WebViewInbox. That fixes defect 2 outright: correlation
is a map lookup keyed by cid (the reference's callbackId table, kept native-side where automation
needs it), bridge.messages never sees internal plumbing, AwaitMessage predicates never scan
past stale results, and an unclaimed result is dropped by forget/clear instead of rotting in
unread.
clear() runs where inbox.clear() runs today — onPageStarted / didStartProvisionalNavigation
— and fails submitted waits with ScriptOutcomeUnknownException immediately. This includes entries
whose raw evaluation has not returned the pending sentinel yet, closing the navigation/arming
race. Registration happens immediately before native submission, so navigation does not invalidate
scripts still waiting for their turn. The exception extends ScriptTimeoutException for existing
read-only polling callers, but its message makes clear that a mutation may already have taken effect. No script is replayed
automatically. Caller cancellation and outer deadlines propagate unchanged.
The platform callbacks no longer discard what they are handed. Both now thread the frame flag and the posting frame's origin the whole way through:
- Android (
addWebMessageListenercallback): the previously-ignoredsourceOrigin: UriandisMainFrameare bound and passed on toinbox.deliver. The listener keepssetOf("*")because lanes navigate anywhere; the frame check is the gate. The platform reports an opaque origin — a sandboxed frame, adata:document — as the literal string"null", which is normalised to a Kotlinnullso no host mistakes it for an origin named "null". - iOS (
WKScriptMessageHandler):message.frameInfo.mainFramefor the flag, andframeInfo.securityOriginreassembled intoscheme://host[:port]— WebKit has no whole-origin string, so it is built from the parts, with an empty host meaning "opaque" and port 0 meaning "the scheme's default" and therefore omitted.
The tag arrives as InboundBridgeMessage(raw, fromMainFrame, sourceOrigin). ScriptResults had
already taken the frame flag for the settle plane; what landed here is the other half, for the
inbox:
Awaits are main-frame-gated. WebViewInbox.deliver queues only main-frame messages for
awaitMatching. Without that, an embedded ad on a third-party site a lane is driving could post
{"type":"ready"} and both satisfy and consume the AwaitMessage armed for the main document
— the forgery from defect 1, one plane over. bridge.awaitMessage's contract now says so.
Subframe traffic is firehose-only. It still reaches bridge.messages, and reaches the new
bridge.inbound carrying its frame and origin. This is what the page→native section below asks
for: a subframe error is information, it just is not an answer.
Subframe messages are not buffered, and that is deliberate. The obvious alternative — queue
them tagged, and let awaitMatching skip them — buffers messages that nothing can ever consume,
since unread has exactly one reader and that reader is now main-frame-only. They would
accumulate until the next navigation's clear(), which is a leak with no upside. If a
subframe-aware await is ever wanted (a step that waits for any frame, or for a named origin),
that is the moment to start buffering tagged entries and to widen the predicate to take an
InboundBridgeMessage — not before.
InboundBridgeMessage also stands in for the BridgeInvocationContext this section used to
propose: same two facts, minus the transport enum until there is a second transport, and it is
public rather than internal because the decision above is exactly the one hosts need to see.
AsyncScript gains a per-controller random nonce (from kotlin.random.Random, generated once
per controller instance):
- the pending sentinel becomes
__wv_pending:<nonce>:<cid>— no longer forgeable or accidentally collidable by page content (defect 3); - the result envelope carries the nonce, and
ScriptResults.deliverrequires nonce and cid to match, so a guessed counter is no longer enough (defect 1, together with the main-frame gate).
The nonce lives only inside the wrapped IIFE's closure and the native table. A main-frame script
that shadows postMessage can capture it — see the honest limit above — but no subframe and no
counter-guesser can.
The settle logic — wrap, evaluate, compare against the sentinel, await the deferred, map
rejection to ScriptFailedException — is string-and-coroutine work with no platform types in it.
It moves to commonMain (an internal AsyncEvaluation helper taking the controller's raw
evaluate: suspend (String) -> String and its ScriptResults), and both controllers call it. The
platform actuals keep only what is genuinely platform: submitting a script and delivering
callbacks.
iOS keeps callAsyncJavaScript? No — it switches to the common path. What it loses: WebKit
compiling the function body outside the page's script-src (the wrapper is evaluated like any
other script), and native await. What it gains: one implementation, one timeout semantics, one
navigation-mid-settle story, one test surface, and the same forged-result gate as Android instead
of a parallel bespoke one. The CSP point is real but narrow — evaluateJavaScript on WKWebView is
not subject to the page's CSP either for the evaluation itself; the difference only bites pages
that break eval-adjacent paths, and no lane has hit it. If one ever does, reintroducing
callAsyncJavaScript behind the same common interface is a platform-actual detail, not a design
change. Until then, two code paths that must agree is exactly the state that produced defect 4.
asJsonExpression / asAwaitedJsonExpression normalisation folds into the common wrapper: the
wrapper already returns the value or the sentinel, so it is the natural single place where
JSON.stringify((v) ?? null) produces the cross-platform encoding the evaluateJs contract
promises.
register<T,R>handler registry,BridgeSerializer,registerNullable,emit/onevents — page→native RPC surface with no Vitre consumer.- Its
emit()string interpolation (unescaped event name — injection). rememberWebViewJsBridge/dispose()lifecycle —WebViewSerializeralready owns ours.BridgeCapabilities— one transport per platform; capability negotiation is complexity on spec.
Page→native is not absent today — window.vitre.postMessage → WebViewInbox →
AwaitMessage / bridge.messages is exactly that, one-way. What the reference adds is the
request/response form, and the two deserve different treatment.
Error reporting is a real page→native consumer, and it is one-way. A driven third-party page
fails in ways the engine currently cannot see: an uncaught exception, an unhandledrejection, a
fetch the page swallowed. Today that surfaces as a locator timing out with no cause attached.
Nothing on the page waits for native's answer to "an error happened" — so this is telemetry on the
existing plane, not RPC. The missing piece is instrumentation: a document-start script (Android
addDocumentStartJavaScript — already proposed above; iOS a WKUserScript, where the bridge
install script already runs) that hooks window.onerror and unhandledrejection and posts a typed
one-way message:
{ "id": "…", "type": "page:error",
"payload": { "message": "…", "source": "…", "stack": "…" } }Hosts observe it on bridge.messages; a workflow can AwaitMessage it; the engine can attach the
last one to a step failure as cause. Two design notes so it composes with the rest of this
document: subframe errors are kept but tagged via InboundBridgeMessage, which is what landed —
the main-frame gate stops a subframe from answering a wait, on either plane, but the observer
firehoses carry every frame's traffic, so a subframe error still reaches a host that is listening;
and inbox.clear() on navigation drops errors from the outgoing
document, which is correct — they could only mislead a step running against the new one.
The direction that does have a caller is the other one: native asks, the page answers. It needed
no new plane, only a convention and two extension functions in TypedBridge.kt over the primitives
that already exist.
The reply names the request in replyTo. A new optional field on the envelope carries the id of
the message being answered; id stays what it always was — this message's own identity. Keeping the
two apart is what lets the settle plane go on reading script:result#<cid> as an identity, and what
keeps a reply distinguishable from its request on the firehose instead of two messages sharing one
id. The field is default-null and default-omitted, so every message written before it existed is
still byte-identical on the wire. bridge.request(type, payload) posts and then awaits
replyTo == id; the page side is one line, echoing the incoming id back (see the sample fixture).
Post-then-await is not a race. A synchronous page handler replies before request has begun
waiting — the common case, not the unlucky one — and the reply still matches, because WebViewInbox
buffers unread messages and the await scans the buffer before it sleeps. That is the inbox's whole
reason to exist; replyTo is what stops the wrong buffered reply from being taken.
Two id namespaces, one channel. Generated ids are msg#<random hex>, deliberately disjoint from
the settle plane's script:result#<cid>, so a reader of bridge.messages can tell page traffic from
internal plumbing at a glance and neither side can be mistaken for the other. post/request refuse
the script:result type outright: ScriptResults claims those before the inbox sees them, so such
a message would be swallowed and the caller would learn about it as a bare timeout.
Payloads are typed by serialization, not by a registry. post, request and awaitMessage
each gained a reified overload that runs the payload through kotlinx.serialization on the way
out and the reply's payload on the way back — request<Ack, Token>("issue-token", Ack(seen = true))
is the whole round trip. These are conveniences over the functions below them and add nothing to
the protocol: same envelope, same id namespace, same reserved-type refusal, same replyTo
correlation, and a typed call is byte-identical on the wire to the hand-built one it replaces. The
typed request returns the reply's payload rather than its ReceivedMessage, because the
envelope's remaining fields are plumbing that call already resolved; when the reply's type is what
distinguishes an answer from a refusal, the untyped overload is still there. ignoreUnknownKeys
means a payload class is a view of what the page sent, so a page that adds a field does not break
a class that predates it — and anything dropped that way is still in ReceivedMessage.raw.
Workflows get the input half only, and that asymmetry is structural. WorkflowScope.postMessage
serializes a payload class into the envelope at build time, which removes the hand-typed JSON string
from the call site. There is deliberately no (T) -> R there: a workflow block is a builder that
runs once, up front, and the step it appends is dispatched later by the engine, so no reply can be
returned to the block that declared it. The reply lands in a variable, and the typing resumes at the
far end — WorkflowEvent.Completed.decodePayload<R>(name) for a message envelope,
decode<R>(name) for anything else a step stored. A caller who wants the round trip as one
expression wants the host API, which is what request is for. id stays a required argument on the
workflow side rather than being generated, because a workflow is a value and one that mints a fresh
id per build is not equal to itself.
Still no handler registry, and no new WorkflowStep. The verdict on the reference stands —
register<T, R> is page→native RPC surface with no consumer here, and this layer registers nothing
and holds no state; it is awaitMessage and postToWebView with the JSON decode moved inside, which
is where WorkflowEngine's AwaitMessage branch was doing it by hand. Workflows already compose the
request shape out of PostMessage + AwaitMessage; what they cannot do is correlate by id, and a
declarative script that generates ids is not a thing a workflow author wants — id correlation is a
programmatic-host concern, so it lives in the host API only. Requests and replies are page traffic
like any other and stay visible on messages and inbound; nothing tries to hide them.
Page→native request/response stays parked. If injected instrumentation ever needs to ask native
something (window.vitre.call(...) returning a Promise — a config lookup, an ack that gates
page behaviour), the reference's shape — callback table in JS, JavaScriptReplyProxy replies,
document-start bootstrap — is the right one, and ScriptResults + BridgeInvocationContext are
the pieces it would sit on. Not built now: nothing on the page needs an answer yet, and it is new
surface on a security-sensitive boundary.
Both directions above assume the page can tell when window.vitre is there. Until now it had
no documented way to find out and had to poll.
The guarantee: existence is the flag. On Android and iOS the bridge is installed before any
page script runs, for the same reason on each — iOS injects a WKUserScript at
WKUserScriptInjectionTimeAtDocumentStart, Android's addWebMessageListener puts its object in
place before the document's own scripts execute. So by the time a line of page JavaScript can ask
the question, the answer is already final, and if (window.vitre) is authoritative and
synchronous. There is no .ready boolean, deliberately: a second flag is only a second thing that
can disagree with the first, and the first cannot be wrong.
The desktop is weaker here, and a page author needs to know it. CEF exposes no document-start
hook to an application — CefRenderProcessHandler::OnContextCreated lives in the render process and
is not reachable from Java — so CefWebViewController injects CefBridgeChannel.installScript()
from onLoadStart, which genuinely races the document's first inline script. Measured, it lands on
both sides of that script across runs of the same page. The check-then-listen pattern below handles
either order and is therefore not optional on this platform, where on the other two it is
belt-and-braces. Nothing the library itself does is affected: a settled-promise report is posted by
a script we evaluate, long after injection, and inbound page traffic is buffered by WebViewInbox
rather than dropped.
The vitre:ready event — belt-and-braces on Android and iOS, load-bearing on the desktop.
Dispatched on window at install, for code that would rather be told than ask. The name is not
vitre — that is the MessageEvent used for native→page delivery, and a listener for one must
never be woken by the other. BridgeReady in commonMain holds the name and the announcement
script, so the constants are one thing rather than a literal per platform.
The pattern is check-then-listen, and on Android and iOS the check is the branch that fires:
function whenBridgeReady(fn) {
if (window.vitre) { fn(); }
else { window.addEventListener('vitre:ready', fn, { once: true }); }
}fn therefore runs synchronously on Android and iOS, and on the desktop only when the check wins
the race. Whatever depends on it belongs inside fn, not on the line after the call — that is the
same intermittent-on-desktop-only bug in a second disguise.
Why the event alone is insufficient. A page script that registers a bare listener and nothing
else hangs by construction on Android and iOS: the announcement precedes every page script there,
so such a listener is always registered after the event it is waiting for has already fired. On
those two the event only ever reaches something that ran earlier still, such as another
document-start script — and on an Android WebView too old for the announcement to be installed at
all, there is no event for anyone to catch. The desktop fails less predictably, and worse: the race
above means a bare listener catches the event on some runs and misses it on others, so the bug ships
instead of showing up the first time anyone tries it. Handling the already-ready case first is what
makes a late listener safe on all three, which is why the sample fixture adopts the helper above
rather than a bare addEventListener.
Where each platform dispatches. iOS does it inline in the install script, after the assignment
and behind the existing if (window.vitre) return; idempotence guard — a re-injection into a
context that already has the bridge must not re-fire ready. Android has no such script, so it uses
WebViewCompat.addDocumentStartJavaScript (idea 3 from the reference, put to its first use), guarded
by WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT) and silently skipped
where the platform WebView is too old. Nothing a page can depend on is lost by that fallback: the
synchronous existence check holds on every WebView, and on Android the event was never the
load-bearing half. The returned ScriptHandler is retained so a close() can remove() it. The
desktop dispatches inline the way iOS does — the event is the last statement in
CefBridgeChannel.installScript, behind the same idempotence guard — so a re-injection into a
context that already has the bridge does not re-fire ready there either.
Order of work, each landable alone:
ScriptResults+ delivery gating (fixes forgery and the shared plane; no API change). Tests: forged result from a subframe is ignored; nonce mismatch is ignored; stale result afterclear()fails the waiter promptly;bridge.messagesnever emitsscript:result;AwaitMessagecannot consume one.- Common
evaluateAndSettle, iOS onto it (deletes the iOS-only path). Tests: existingAsyncScriptTestplus sentinel-collision ("__wv_pending:…"as a legitimate value) and navigation-mid-settle; run thebridge-round-tripand live-probe sample workflows on both platforms. - Delete
awaitsPromises(pools drop the argument; hosts change behaviour). TheWebViewController.evaluateJsKDoc and this document are the record of the new contract.
FakeWebViewController needs no change — it never had the flag. WebViewInboxTest gains the
"results never reach the inbox" cases; AsyncScriptTest largely survives with the nonce threaded
through.