Message Envelope
The request shape, type dispatch, reply shape, and transport requirements.
Request
Every message is a JSON-serialisable object with a required string type.
Capability-specific fields sit alongside it on the same object — the envelope is
flat, not nested.
type BridgeMessage =
| { type: 'track'; ename: string; parameters?: Record<string, string | number | boolean> }
| { type: 'headers' }
| { type: 'refreshToken' }
| { type: 'establishSession'; refresh?: boolean }typeMUST be present and a string.- The host MUST dispatch on
typealone. - Additional fields not described by a capability SHOULD be ignored by the host (forward compatibility).
Reply
The host answers with a settled Promise on the web side:
- Resolve with the capability's result value (which MAY be
undefined). - Reject with an error — see Error handling.
There is no envelope around the reply: the resolved value is the capability's
result, not a { data } wrapper. Errors are the one exception, because the
transports encode them out-of-band (an error string, not a thrown value).
Type dispatch
┌── "track" → enrich + forward, resolve undefined
message.type├── "headers" → resolve Record<string,string>
├── "refreshToken" → refresh, resolve { authorization } | reject
├── "establishSession" → mint cookie natively, resolve { ok: boolean }
└── anything else → resolve undefinedAn unrecognised or missing type MUST resolve with undefined. It MUST
NOT reject. This is the forward-compatibility hinge: a web build that calls a
capability an older host has never heard of gets undefined rather than a crash.
Transport requirements
The contract is transport-agnostic, but both platforms MUST present the channel
as an object with a promise-returning postMessage:
interface NativeChannel {
postMessage(message: BridgeMessage): Promise<unknown>
}| Platform | Where the channel lives | Underlying mechanism |
|---|---|---|
| iOS | window.webkit.messageHandlers.todayWebViewBridge | WKScriptMessageHandlerWithReply (postMessage already returns a Promise) |
| Android | window.todayWebViewBridge | Host-injected JS shim that returns a Promise (see below) |
The host MUST satisfy these rules:
- Serialisation. Messages are structured-cloneable JSON. The host MUST accept any value the capabilities define and SHOULD tolerate unknown fields.
- One settlement per message. Each
postMessagesettles exactly once. - No ordering guarantee. Replies MAY arrive out of order relative to requests. Capabilities that need correlation carry their own identifiers; the v1 capabilities do not require any.
- Idempotent transport. Re-sending the same message MUST be safe at the
transport layer (capability-level effects are defined per capability — e.g.
refreshTokencoalescing). - Token-bearing capabilities are main-frame only. The host MUST expose
headers,refreshToken, andestablishSessionto the top-level document frame only. A call dispatched from a cross-origin sub-frame MUST resolveundefined; a token MUST NOT cross into a sub-frame (and no cookie is minted).trackcarries no token and is not restricted. See Capabilities.
Android promise semantics
iOS's WKScriptMessageHandlerWithReply returns a Promise natively. Android's raw
@JavascriptInterface is synchronous and string-only, so a conforming Android
host MUST inject a thin JS shim that presents the same
postMessage(message): Promise surface — typically backed by AndroidX
WebViewCompat.addWebMessageListener with request-id correlation, or by a
synchronous @JavascriptInterface call paired with a callback. The web side never
sees the difference. See Android.