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}