Skip to main content

arora_bridge/
lib.rs

1//! The Arora Bridge interface.
2//!
3//! A [`Bridge`] connects an Arora runtime to a remote — in practice Semio Studio
4//! over `studio-bridge`. It is modelled on studio-bridge's `device-client`
5//! trait: push local state changes out, receive device-info updates and
6//! commands in, and learn when a client is asking for data.
7//!
8//! # The seam: an owned inbound stream, a non-blocking outbound push
9//!
10//! A `Bridge` value is an **endpoint**: the connection as seen by exactly one
11//! device, owned by exactly one runtime. Its data plane is two methods:
12//!
13//! - [`take_inbound`](Bridge::take_inbound) hands over the endpoint's inbound
14//!   event stream — **once**, at assembly. The runtime owns the stream from
15//!   then on and polls it from its own loop (natively `run`'s select drains it
16//!   between steps; on the web the per-frame sweep does). The stream
17//!   **ending** means the endpoint disconnected; the runtime maps that
18//!   explicitly, it is never silent.
19//! - [`try_send`](Bridge::try_send) pushes an outbound [`StateChange`] toward
20//!   the remote, now, without blocking (a channel send / synchronous put).
21//!
22//! Exclusive ownership is the point: one poller per endpoint, so there is no
23//! shared receiver and **no lock anywhere on the data plane**. An
24//! implementation whose transport genuinely serves several devices (one Zenoh
25//! session, one WebSocket server) keeps that transport in a shared *core* and
26//! hands out one endpoint per device, demuxing inbound events to the right
27//! endpoint's stream — share the core, not the endpoint.
28//!
29//! The trait depends on `futures-core` (for [`Stream`]) but on **no async
30//! runtime**:
31//! native impls may feed the stream from their own tokio task, web impls from
32//! browser events; the runtime only polls.
33//!
34//! The trait lives here (lean: `arora-types` + stream primitives) so the
35//! runtime can depend on the *interface* without depending on `studio-bridge`.
36//! studio-bridge keeps its device-client implementations and implements this
37//! trait on them.
38
39use std::pin::Pin;
40
41use async_trait::async_trait;
42use futures_channel::oneshot;
43use futures_core::Stream;
44
45use arora_types::call::{Call, CallError, CallResult};
46use arora_types::data::{Key, StateChange};
47
48/// Neutral device metadata the bridge syncs with the remote. The bridge-flavored
49/// wire form (studio-bridge's `PartialDeviceInfo`) is converted to/from this by
50/// the connector.
51#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub struct DeviceInfo {
53    pub name: Option<String>,
54    pub description: Option<String>,
55    pub model_family: Option<String>,
56    pub hardware_version: Option<String>,
57    pub software_version: Option<String>,
58    pub owners: Vec<String>,
59}
60
61/// An operation a remote client asks the device to perform. Mirrors
62/// studio-bridge's `AroraOp`.
63#[derive(Debug, Clone)]
64pub enum BridgeOp {
65    /// Read the given keys.
66    Get(Vec<Key>),
67    /// Apply a state change.
68    Update(StateChange),
69    /// Call a function.
70    Call(Call),
71    /// Enumerate store keys under an optional path prefix — introspection for
72    /// the live-edit surface. Replies with a [`CallResult`] whose `ret` is an
73    /// `ArrayValue` of the matching key paths as `String`s.
74    ListKeys {
75        /// Only keys whose path starts with this prefix; `None` lists all.
76        prefix: Option<String>,
77    },
78    /// Enumerate callable module methods under an optional name prefix. Replies
79    /// with a [`CallResult`] whose `ret` is an `ArrayValue` of method names as
80    /// `String`s.
81    ListMethods {
82        /// Only methods whose name starts with this prefix; `None` lists all.
83        prefix: Option<String>,
84    },
85}
86
87/// Something went wrong on the bridge.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum BridgeError {
90    /// The link to the remote dropped.
91    Disconnected(String),
92    /// The device was unregistered from the remote — the runtime should stop.
93    Unregistered,
94    /// Anything else, with a message.
95    Other(String),
96}
97
98impl std::fmt::Display for BridgeError {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        match self {
101            BridgeError::Disconnected(m) => write!(f, "bridge disconnected: {m}"),
102            BridgeError::Unregistered => write!(f, "device unregistered from the remote"),
103            BridgeError::Other(m) => write!(f, "{m}"),
104        }
105    }
106}
107
108impl std::error::Error for BridgeError {}
109
110pub type BridgeResult<T> = Result<T, BridgeError>;
111
112/// A command received from the remote, carrying a one-shot reply channel.
113///
114/// Process [`op`](BridgeCommand::op), then call [`reply`](BridgeCommand::reply)
115/// exactly once with the result (mirrors device-client's
116/// `(AroraOp, oneshot::Sender<Result<AroraCallResult, String>>)`).
117pub struct BridgeCommand {
118    pub op: BridgeOp,
119    reply: oneshot::Sender<Result<CallResult, String>>,
120}
121
122impl BridgeCommand {
123    /// Build a command from an op and its reply channel (for `Bridge` impls).
124    pub fn new(op: BridgeOp, reply: oneshot::Sender<Result<CallResult, String>>) -> Self {
125        Self { op, reply }
126    }
127
128    /// Send the result back to the remote. Ignores a dropped receiver.
129    pub fn reply(self, result: Result<CallResult, String>) {
130        let _ = self.reply.send(result);
131    }
132}
133
134/// The device operator's answer to an [`AccessRequest`].
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum AccessDecision {
137    Allowed,
138    Rejected,
139}
140
141/// A remote client asking permission to interact with the device — e.g. a
142/// Studio client wanting to join the device's session (Studio's "session
143/// joining access control").
144///
145/// The bridge surfaces these through [`Bridge::access_requests`]; whoever
146/// consumes the stream (an operator UI, a headless auto-approval policy)
147/// answers with [`respond`](AccessRequest::respond). Dropping the request
148/// unanswered leaves the decision to the bridge, which typically treats it as
149/// a rejection.
150pub struct AccessRequest {
151    /// The requesting client's session id (e.g. the Studio client id).
152    pub client_id: String,
153    /// The user behind the client (e.g. the Firebase user id found in the
154    /// device's permission lists), when the transport carries it.
155    pub user_id: Option<String>,
156    /// What is being requested, human-readable and ready to embed in a prompt
157    /// — e.g. "join the session", "claim the device".
158    pub permission: String,
159    responder: oneshot::Sender<AccessDecision>,
160}
161
162impl AccessRequest {
163    /// Build a request plus the receiver its decision arrives on (for `Bridge`
164    /// impls).
165    pub fn new(
166        client_id: impl Into<String>,
167        user_id: Option<String>,
168        permission: impl Into<String>,
169    ) -> (Self, oneshot::Receiver<AccessDecision>) {
170        let (responder, rx) = oneshot::channel();
171        (
172            Self {
173                client_id: client_id.into(),
174                user_id,
175                permission: permission.into(),
176                responder,
177            },
178            rx,
179        )
180    }
181
182    /// Answer the request. Ignores a dropped receiver.
183    pub fn respond(self, decision: AccessDecision) {
184        let _ = self.responder.send(decision);
185    }
186}
187
188impl std::fmt::Debug for AccessRequest {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        f.debug_struct("AccessRequest")
191            .field("client_id", &self.client_id)
192            .field("user_id", &self.user_id)
193            .field("permission", &self.permission)
194            .finish_non_exhaustive()
195    }
196}
197
198/// Stream of access requests from remote clients.
199pub type AccessRequestStream = Pin<Box<dyn Stream<Item = AccessRequest> + Send>>;
200
201/// An inbound event on a bridge endpoint, yielded by the stream
202/// [`Bridge::take_inbound`] hands over.
203///
204/// The implementation feeds these from its own transport (a task, a browser
205/// callback); the runtime's loop buffers them and applies them in arrival
206/// order during its step.
207pub enum Inbound {
208    /// A command from the remote, carrying its one-shot reply channel.
209    Command(BridgeCommand),
210    /// A device-info update. `Ok(None)` means this remote no longer knows the
211    /// device — it says nothing about the device, which serves whoever else it
212    /// is attached to.
213    DeviceInfo(BridgeResult<Option<DeviceInfo>>),
214    /// Whether anyone over this endpoint wants the device's data. Any number
215    /// of clients may ask at once, so this is the aggregate: `true` while at
216    /// least one does.
217    DataRequested(bool),
218}
219
220/// The inbound event stream of one bridge endpoint. Owned: whoever takes it is
221/// the endpoint's one poller. The stream ending means the endpoint
222/// disconnected.
223pub type InboundStream = Pin<Box<dyn Stream<Item = Inbound> + Send>>;
224
225/// A [`Caller`]'s pending reply.
226pub type CallFuture<'a> =
227    Pin<Box<dyn std::future::Future<Output = Result<CallResult, CallError>> + Send + 'a>>;
228
229/// The client side of calling a device — the counterpart of a [`Bridge`]
230/// endpoint: carry a fully-specified [`Call`] to a device and resolve on its
231/// reply. A device applies external calls at a step boundary, so calling is
232/// asynchronous by nature wherever the device lives — in the same process or
233/// across a transport.
234pub trait Caller {
235    /// Dispatch `call`, resolving on the device's reply.
236    fn call(&self, call: Call) -> CallFuture<'_>;
237}
238
239/// One device's connection to a remote (e.g. Semio Studio) — an **endpoint**,
240/// owned by exactly one runtime.
241///
242/// Modelled on studio-bridge's `device-client`. The data plane is exclusive
243/// (`&mut self`, no lock): the runtime takes the inbound stream once at
244/// assembly and pushes outbound changes as it steps. The remaining methods are
245/// the control plane (device identity/info, access requests), used around the
246/// loop rather than inside it.
247#[async_trait]
248pub trait Bridge: Send + Sync {
249    /// Hand over the endpoint's inbound event stream. Called **once**, at
250    /// assembly; the caller owns the stream from then on (one poller per
251    /// endpoint). The stream ending means the endpoint disconnected.
252    ///
253    /// Implementations typically move an internal channel receiver out here
254    /// (and may panic on a second call — taking twice is a programming error).
255    fn take_inbound(&mut self) -> InboundStream;
256
257    /// Push an outbound state change toward the remote, now, without blocking.
258    ///
259    /// The implementation enqueues it onto its own outbound channel/transport;
260    /// it must not block the caller's step.
261    fn try_send(&mut self, change: &StateChange);
262
263    /// The device's current info, if registered.
264    async fn get_device_info(&self) -> BridgeResult<Option<DeviceInfo>>;
265
266    /// Push updated device info to the remote; returns the merged result.
267    async fn update_device_info(
268        &self,
269        info: Option<DeviceInfo>,
270    ) -> BridgeResult<Option<DeviceInfo>>;
271
272    /// The device's identity on the remote (e.g. its Firestore document id),
273    /// when the bridge has one. Defaults to `None` for bridges without a
274    /// backend identity.
275    async fn device_id(&self) -> Option<String> {
276        None
277    }
278
279    /// A stream of [`AccessRequest`]s from remote clients — Studio's "session
280    /// joining access control". Defaults to a stream that never yields, for
281    /// bridges whose remote does not (yet) send access requests; such bridges
282    /// keep their current behavior of granting access implicitly.
283    ///
284    /// This is a separate concern from the inbound data plane
285    /// ([`take_inbound`](Bridge::take_inbound)): the operator front end serves
286    /// it on its own task, one request at a time.
287    async fn access_requests(&self) -> AccessRequestStream {
288        Box::pin(futures_util::stream::pending())
289    }
290}
291
292/// A no-op [`Bridge`] for tests and offline runs: never registers, never emits
293/// inbound events (its stream never yields — the endpoint never disconnects),
294/// and accepts (drops) any data sent.
295#[derive(Clone, Default)]
296pub struct FakeBridge;
297
298impl FakeBridge {
299    pub fn new() -> Self {
300        Self
301    }
302}
303
304#[async_trait]
305impl Bridge for FakeBridge {
306    fn take_inbound(&mut self) -> InboundStream {
307        Box::pin(futures_util::stream::pending())
308    }
309
310    fn try_send(&mut self, _change: &StateChange) {}
311
312    async fn get_device_info(&self) -> BridgeResult<Option<DeviceInfo>> {
313        Ok(None)
314    }
315
316    async fn update_device_info(
317        &self,
318        info: Option<DeviceInfo>,
319    ) -> BridgeResult<Option<DeviceInfo>> {
320        Ok(info)
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use futures::StreamExt;
328
329    #[tokio::test]
330    async fn fake_bridge_is_usable_as_trait_object() {
331        let mut bridge: Box<dyn Bridge> = Box::new(FakeBridge::new());
332        assert_eq!(bridge.get_device_info().await.unwrap(), None);
333        // The inbound stream never yields (the fake never disconnects), and
334        // outbound is dropped.
335        let mut inbound = bridge.take_inbound();
336        assert!(futures::poll!(inbound.next()).is_pending());
337        bridge.try_send(&StateChange::new());
338    }
339
340    #[tokio::test]
341    async fn command_reply_round_trips() {
342        let (tx, rx) = oneshot::channel();
343        let cmd = BridgeCommand::new(BridgeOp::Get(vec![Key::from("a")]), tx);
344        match &cmd.op {
345            BridgeOp::Get(keys) => assert_eq!(keys[0], Key::from("a")),
346            _ => panic!("wrong op"),
347        }
348        cmd.reply(Err("not implemented".to_string()));
349        assert!(rx.await.unwrap().is_err());
350    }
351
352    #[tokio::test]
353    async fn access_request_decision_round_trips() {
354        let (req, rx) = AccessRequest::new("client-1", Some("user-1".into()), "join the session");
355        assert_eq!(req.client_id, "client-1");
356        assert_eq!(req.permission, "join the session");
357        req.respond(AccessDecision::Allowed);
358        assert_eq!(rx.await.unwrap(), AccessDecision::Allowed);
359    }
360
361    #[tokio::test]
362    async fn dropping_an_access_request_cancels_its_decision() {
363        let (req, rx) = AccessRequest::new("client-1", None, "claim the device");
364        drop(req);
365        assert!(rx.await.is_err(), "no decision should arrive");
366    }
367
368    #[tokio::test]
369    async fn default_bridge_has_no_identity_and_a_pending_request_stream() {
370        let bridge: Box<dyn Bridge> = Box::new(FakeBridge::new());
371        assert_eq!(bridge.device_id().await, None);
372        // The default stream never yields (it must NOT terminate — a finished
373        // stream would look like "access control is over" to a select loop).
374        let mut requests = bridge.access_requests().await;
375        assert!(futures::poll!(requests.next()).is_pending());
376    }
377}