Server-sent events

← Observers · Documentation · Next: Compatibility →

SSE incrementally changes a view when data or layout is not available in one payload. An SSE action is the same action a widget dispatches—there is no second mutation format or renderer.

Embedding a stream

BduiView(
    initialJson = shellJson,
    streamUrl = "/v1/offers/42/bdui/stream",
    modifier = Modifier.fillMaxWidth(),
)
InitBehavior
stream onlyLoading until snapshot/root replacement
JSON + streamRender shell immediately, then update

Wire format

id: 101
event: action
data: {"type":"patch","patch":[{"op":"add","path":"$root/state/status","value":"ready"}]}

id: 102
event: action
data: {"type":"patch","patch":[{"op":"replace","path":"$id.slot-gallery","value":{"id":"slot-gallery","type":"Image","params":{"url":"https://cdn.example.com/1.jpg","contentDescription":"Offer"}}}]}

id: 103
event: snapshot
data: {"id":"root","type":"Column","state":{},"children":[]}
EventDataBehavior
action or omittedAction JSONDispatch through BduiActionRuntime
snapshotRoot widgetCommit as root after compatibility validation
ping or : commentnoneKeepalive

Unknown event names and incompatible actions are logged/skipped. Malformed data is logged and routed to the root error observer when possible; the connection stays alive.

Multiple ordered changes use one sync action:

event: action
data: {"type":"sync","actions":[{"type":"patch","patch":[{"op":"add","path":"$root/state/loading","value":false}]},{"type":"patch","patch":[{"op":"add","path":"$root/state/status","value":"ready"}]}]}

Connection lifecycle

BduiSseSession uses the injected Ktor client:

  1. GET streamUrl with Accept: text/event-stream, app authentication, and X-Bdui-Spec.
  2. Decode each event off the UI thread.
  3. Enqueue events in arrival order on the same runtime as UI actions.
  4. Record the latest successfully accepted event id.
  5. Reconnect with Last-Event-ID.
  6. Exponential backoff with jitter until this BduiView leaves composition.
  7. Stop when the view is disposed.

While disconnected, keep displaying the last valid tree. Connection diagnostics are host policy; avoid repeated user toasts.

Ordering and generation

SSE transport order is not enough when a server restarts/resyncs. An action/snapshot may carry:

{
  "type": "patch",
  "generation": 12,
  "patch": [
    { "op": "merge", "path": "$root/state", "value": { "status": "ready" } }
  ]
}

generation is a monotonic revision for this view:

  • absent: accept in transport order;
  • lower than store generation: drop as stale;
  • equal: accept only when event ID is newer/idempotent;
  • higher: apply and update generation.

Root snapshot establishes generation. Server replay should be idempotent or protected by generation/event IDs.

Filling slots

Initial shell:

{
  "id": "root",
  "type": "Column",
  "state": { "title": "Offer", "loading": true },
  "children": [
    { "type": "Text", "params": { "text": "$root.title" } },
    {
      "id": "slot-gallery",
      "type": "Box",
      "params": { "contentAlignment": "Center" },
      "children": [{ "type": "CircularProgress" }]
    },
    {
      "id": "slot-reviews",
      "type": "LazyColumn",
      "children": []
    }
  ]
}

Replace gallery:

event: action
data: {"type":"patch","patch":[{"op":"replace","path":"$id.slot-gallery","value":{"id":"slot-gallery","type":"Image","params":{"url":"https://cdn.example.com/hero.jpg","contentDescription":"Offer"}}}]}

Append review:

event: action
data: {"type":"patch","patch":[{"op":"add","path":"$id.slot-reviews/children/-","value":{"id":"review-1","type":"Text","params":{"text":"Great location"}}}]}

State and tree in one event:

event: action
data: {"type":"patch","patch":[{"op":"merge","path":"$root/state","value":{"price":"25 000 000 ₽","loading":false}},{"op":"replace","path":"$id.slot-gallery","value":{"id":"slot-gallery","type":"Image","params":{"url":"https://cdn.example.com/hero.jpg","contentDescription":"Offer"}}},{"op":"add","path":"$id.slot-reviews/children/-","value":{"id":"review-1","type":"Text","params":{"text":"Great location"}}}]}

Prefer $id.* and subtree replacement over numeric pointers from root because sibling indices shift. Keep replacement root IDs stable to preserve Compose identity. $local / $parent are invalid on SSE patches.

State-only events

The tree does not need to change for data:

event: action
data: {"type":"patch","patch":[{"op":"merge","path":"$root/state","value":{"price":"25 000 000 ₽","loading":false}}]}

Any widget bound to these paths recomposes.

Outbound traffic

SSE is one-way. UI-to-server work uses http:

{
  "type": "async",
  "actions": [
    { "type": "patch", "patch": [{ "op": "add", "path": "$id.offer-42/state/liked", "value": true }] },
    {
      "type": "http",
      "method": "POST",
      "url": "/v1/likes",
      "body": { "fromState": "$id.offer-42" },
      "success": { "apply": "none" },
      "onError": {
        "type": "patch",
        "patch": [{ "op": "add", "path": "$id.offer-42/state/liked", "value": false }]
      }
    }
  ]
}

The server can later publish the authoritative count/state.

AppSana transform (POST SSE)

BduiView(streamUrl) is GET-only. AppSana's /api/bdui is request-scoped: it needs the current widget document plus a natural-language query, so the host POSTs once and applies the same action / snapshot / ping events.

Bdui.init(
    BduiConfig {
        httpClient = appHttpClient
        appsana = BduiAppsana(
            url = "https://appsana-platform.vercel.app/api/bdui",
            apiKey = appsanaApiKey,
        )
    },
)

BduiAppsanaClient(config, runtime).transform("Make the title larger")

The client sends { "ui": <store snapshot>, "query": "..." } with Accept: text/event-stream, Authorization: Bearer, X-Appsana-Key, and X-Bdui-Spec. Do not put the API key in widget JSON. There is no reconnect: one request produces a patch action (or an error toast). The host HTTP client should disable OkHttp read/call timeouts for this POST — the snapshot is a large data: line and the model can take tens of seconds.

The Android sample reads appsana.url and appsana.apiKey from local.properties (see local.properties.example).

Sensitive data

Root-only sensitiveData lists patch paths that must not leave the device on an AppSana POST. Put personal values in state, bind widgets to $root.*, and declare the pointers:

{
  "id": "root",
  "type": "Column",
  "sensitiveData": [
    "$root/state/userName",
    "$root/state/profile"
  ],
  "state": {
    "userName": "Ivan",
    "profile": { "phone": "+7 …", "locale": "ru" }
  },
  "children": [
    { "type": "Text", "params": { "text": "$root.userName" } }
  ]
}

BduiAppsanaClient clones the snapshot and replaces leaves under those paths with { "$bdui": "sensitive", "ref": "$root/state/userName" }. The live store is unchanged. Host BduiConfig.sensitiveData is unioned with the document list. Inbound AppSana snapshots and patches are grafted back from the live store: the model cannot overwrite those leaves, cannot clear sensitiveData, and a sentinel copied into params is rewritten to a binding ($root.userName) instead of inlining the real value.

Company streamUrl SSE is not tokenized.

Register Tools that return personal data with sensitive = true. The SDK will not POST their result to AppSana for server-side compose; a client mapper must map the result on the device.

Error behavior

  • transport loss: reconnect; keep tree;
  • malformed event: log, general error observer, continue;
  • action execution failure: normal action onError + error observers;
  • incompatible child/action: skip and continue;
  • incompatible root snapshot: keep current valid tree and ask host for update UI;
  • unreplayable gap: server sends a current snapshot.

SSE failures never write directly from the socket thread and never terminate composition.

← Observers · Documentation · Next: Compatibility →

AppSana — экраны под задачу в приложениях, которые у вас уже есть.