Architecture and lifecycle
← Documentation · Next: State →
Mental model
BDUI has five core concepts:
| Concept | Responsibility |
|---|---|
| Widget tree | Recursive JSON layout. Containers own children; leaves do not |
| State | JSON attached to root or individual widget nodes |
| Action | Serializable instruction for a mutation, request, host effect, or group |
| Store | Owns the current tree and commits all mutations atomically |
| Theme | Semantic tokens layered from config, host app, and optional backend overrides |
Bdui.init(config)
│
BduiView(initialJson?, streamUrl?)
│
├── JSON snapshot ──┐
└── Ktor SSE ───────┤
▼
BduiActionRuntime
serialized commit queue
│
┌─────────────┴─────────────┐
▼ ▼
BduiStore mutation BduiEffectBus
│ │
▼ ▼
Compose recomposition host one-shot effect
│
▼
observer evaluation
All mutations—UI events, observers, HTTP responses, and SSE events—use the same runtime. Network callbacks and custom handlers never mutate BduiStore directly. State, tree, and backend theme writes are patch (RFC 6902 plus BDUI node roots and merge).
Embedding a view
BduiView is a Compose composable. Place it anywhere the host already composes: a full destination, a tab, a card, a list header, a bottom sheet, or beside native widgets. It is not a navigation destination and does not own the back stack.
Call Bdui.init once at app start with the default BduiConfig. Each view may pass an optional config to override it.
Bdui.init(
BduiConfig {
httpClient = appHttpClient
navigator = appNavigator
notifier = appNotifier
theme = AppBduiTheme
}
)
@Composable
fun BduiView(
config: BduiConfig? = null,
initialJson: String? = null,
streamUrl: String? = null,
modifier: Modifier = Modifier,
)
Omitted config uses the instance from Bdui.init. Missing both Bdui.init and config is invalid.
Column {
Text("Native title")
BduiView(
initialJson = payload,
modifier = Modifier.fillMaxWidth(),
)
Button(onClick = onHostCta) { Text("Native action") }
}
JSON, stream, or both:
BduiView(initialJson = payload)
BduiView(streamUrl = "/v1/bdui/offer/42/stream")
BduiView(initialJson = shell, streamUrl = stream)
| Input | Initial behavior |
|---|---|
initialJson only | Decode and display immediately |
streamUrl only | Stay loading until a snapshot or a patch that replaces $root arrives |
| Both | Display the shell immediately, then apply stream actions |
| Neither | Invalid initialization; the host shows an error in that view's slot |
Each BduiView owns an isolated store and runtime. The default BduiConfig from Bdui.init is shared. Several views may appear on the same host screen; pass config only when a view needs different registries, theme, or HTTP.
LazyColumn {
item {
BduiView(
initialJson = heroJson,
modifier = Modifier.fillMaxWidth(),
)
}
items(nativeRows) { NativeRow(it) }
item {
BduiView(
initialJson = reviewsShell,
streamUrl = reviewsStream,
modifier = Modifier.fillMaxWidth(),
)
}
}
Leaving composition disposes that view's runtime and SSE. Host navigation (navigate) is an effect the app implements for native destinations. Full-screen BDUI destinations use BduiScreenHost and openScreen.
Full-screen hosts
BduiView never owns a back stack. BduiScreenHost does: it renders an embeddable root view, then presents each openScreen destination as a window-filling screen with its own store, runtime, optional initialJson, and optional streamUrl.
@Composable
fun BduiScreenHost(
config: BduiConfig? = null,
initialJson: String? = null,
streamUrl: String? = null,
modifier: Modifier = Modifier,
)
Place it at the activity/window root (or anywhere a full-screen overlay is acceptable). The opened screen covers the entire app window; it is not clipped to the host's embedded slot.
BduiScreenHost(
initialJson = payload,
streamUrl = stream,
modifier = Modifier.fillMaxSize(),
)
Each pushed screen is isolated like a separate BduiView. Closing it with popScreen (or system back / dismiss when no dialog overlay is open) delivers an optional JSON result to the caller's onResult as $event.
Config
BduiConfig is the integration surface. The app supplies a default with Bdui.init and may pass another instance into a specific BduiView.
Bdui.init(
BduiConfig {
httpClient = appHttpClient
navigator = appNavigator
notifier = appNotifier
theme = AppBduiTheme
backendThemePolicy = BduiThemePolicy(
allowOverrides = true,
allowedSections = setOf(
"colors", "typography", "shapes", "spacing", "components",
),
allowedFontFamilies = setOf("brand", "system"),
)
widgets.register("acme:Map", AcmeMapRenderer)
actions.register("acme:favoriteOffer", FavoriteOfferHandler)
}
)
BduiView(
initialJson = payload,
streamUrl = streamUrl,
modifier = Modifier.fillMaxWidth(),
)
The config owns:
- the authenticated Ktor
HttpClient; - optional
BduiAppsanaendpoint for POST SSE transforms; - host navigation, dialog overlays, notifications, clipboard, sharing, and URL handling;
- theme defaults and backend-override policy;
- custom widget and action registries;
- optional
sensitiveDataextra paths (unioned with the document's rootsensitiveData); - JavaScript execution policy;
- client spec version;
- optional persistence/checkpoint provider.
Registries belong to that BduiConfig, not the process. Tests pass an isolated config into BduiView or Bdui.init.
Widget document
Every widget uses the same envelope:
{
"id": "offer-42",
"type": "Card",
"state": { "liked": false },
"modifier": [
{ "name": "fillMaxWidth" },
{ "name": "paddingHorizontal", "value": "16" }
],
"onClick": { "type": "navigate", "screen": "offer" },
"params": {
"enabled": true,
"visible": true,
"variant": "elevated"
},
"observers": [],
"children": []
}
| Field | Type | Required | Meaning |
|---|---|---|---|
type | string | yes | Case-sensitive built-in or registered widget name |
id | string | no | Stable identity and named state/tree target. Root defaults to root |
state | object | no | State owned by this node |
modifier | modifier[] | no | Layout modifiers, applied left-to-right. See themes |
onClick | action | no | Makes this widget clickable. Absent means not clickable |
params | object | no | Widget-specific params plus common visible / enabled |
children | array | containers only | Child widgets. Omit for leaves |
topBar / bottomBar / floatingActionButton | widget | Scaffold only | Named layout slots; same widget envelope as children |
observers | array | no | State and error observers declared on this node |
theme | object | root only | Restricted theme-token override |
sensitiveData | string[] | root only | Patch paths whose values are tokenized before an AppSana transform. See streaming |
minSpec | integer | no | Skip this node on older clients |
modifier and onClick are envelope fields, not params. Any widget type may set onClick; BDUI applies clickable. params.enabled: false suppresses it. A child onClick consumes the click so ancestors do not also fire.
Unknown fields are ignored. Unknown or too-new widgets and their subtrees are skipped and logged; they do not crash, display a BDUI placeholder, or produce a toast.
The Kotlin model:
@Serializable
data class BduiWidgetModel(
val type: String,
val id: String? = null,
val state: JsonObject? = null,
val modifier: List<BduiModifier> = emptyList(),
val onClick: BduiAction? = null,
val params: Map<String, JsonElement> = emptyMap(),
val children: List<BduiWidgetModel> = emptyList(),
val observers: List<BduiObserver> = emptyList(),
val theme: BduiThemeOverride? = null,
val minSpec: Int? = null,
val sensitiveData: List<String> = emptyList(),
)
Rendering
BduiView owns a view-scoped store and runtime. It renders loading until a root exists, then recursively dispatches by type.
Host layout
└── BduiView
└── BduiWidget(root)
├── resolve state/theme paths
├── build Modifier from envelope `modifier`
├── apply clickable from envelope `onClick`
├── render built-in or registered composable
└── render children if this type is a container
Compose uses id as identity when present. Stable IDs preserve focus, lazy-list identity, and local state while nearby streamed subtrees change.
Runtime invariants
- The tree is the only render document.
- The store is the only mutable UI source of truth.
- Every store write is serialized and atomic.
- Effects never enter state and are consumed once.
- Widget rendering never performs network or native reflection.
- Extension handlers receive resolved data and dispatch actions; they do not receive the store.
- Malformed input becomes a logged skip or an action error, never an exception from composition.
Process death and checkpoints
Configuration changes keep the in-memory view store. Process death may restore a serialized checkpoint:
@Serializable
data class BduiCheckpoint(
val viewKey: String,
val tree: BduiWidgetModel,
val lastEventId: String? = null,
val generation: Long? = null,
val savedAtEpochMs: Long,
)
Persist tree + node states + theme and the SSE cursor. Do not persist effects, running actions, HTTP requests, $event, $response, or $error.
On cold start:
- Load a fresh, compatible checkpoint for
viewKey. - Commit it as the initial tree without firing initial observers.
- Reconnect SSE with
Last-Event-ID. - If missing, expired, or incompatible, initialize from
initialJson/streamUrl.
Small form-only state may use platform saved state. Full trees belong in file-backed storage. Checkpoints need a TTL and schema/spec compatibility check.
Multiplatform layout
commonMain
BduiConfig, BduiSpec, BduiJsPolicy
model/BduiWidgetModel
store/BduiStore
action/BduiActionRuntime
observer/BduiObserverEngine
sse/BduiSseSession
theme/BduiTheme
widget catalog and extension registries
expect host/platform capabilities
androidMain / iosMain / desktopMain / jsMain / wasmJsMain
actual JavaScript sandbox
navigator, notifier, clipboard, sharing, URI handler
The host places BduiView in any Compose (or platform) layout. A Fragment/UIViewController wrapper is optional host code, not part of the common protocol.