Skip to main content

consortium_hmi/bridge/
mod.rs

1// Copyright 2026 Ethan Wu
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17//! The command bridge: register a handler, dispatch an invocation, hand back a future.
18//!
19//! [`Bridge`] is a name-to-handler map and nothing more. A UI runtime hands it a JSON
20//! envelope, it parses out the command name and correlation id, looks up the handler, and
21//! returns a [`Dispatch`] carrying that id plus a [`ResponseFuture`].
22//!
23//! # Why it returns a future instead of a response
24//!
25//! The bridge does not poll. It cannot: a Cog/WebKit adapter runs on a GLib main loop, a
26//! PocketJS adapter delivers responses at frame boundaries, and neither owns a Tokio
27//! runtime this crate could assume. So the bridge produces the future and the **adapter**
28//! decides where to poll it and how to get the JSON back into its guest runtime.
29//!
30//! That is what keeps `consortium-hmi` free of GLib, WebKit, QuickJS, and Tokio, and what
31//! lets one set of product services serve both backends.
32//!
33//! # Sync and async handlers
34//!
35//! [`Bridge::handle`] takes a plain `Fn(String) -> String`; [`Bridge::handle_async`] takes
36//! one returning a future. Both are erased into the same internal handler type and both
37//! produce a [`ResponseFuture`], so a caller cannot tell them apart — a handler can become
38//! async without touching the adapter.
39//!
40//! # Correlation ids are the guest's
41//!
42//! [`Dispatch::id`] is echoed from the invocation envelope, not generated here. The guest
43//! runtime owns request/response matching, because it is the side with concurrent
44//! outstanding calls; the bridge only has to preserve the id it was given.
45//!
46//! Payloads stay as JSON strings end to end. The bridge never deserializes a payload — a
47//! handler does that, with whatever type it wants.
48
49mod handler;
50mod invocation;
51
52use std::{
53    collections::HashMap,
54    error::Error,
55    fmt,
56    future::Future,
57    pin::Pin,
58    sync::{Arc, Mutex},
59};
60
61use self::{handler::Handler, invocation::parse_invocation};
62
63/// A backend-independent command response.
64///
65/// Adapters decide where to poll this future and how to deliver its JSON value
66/// to their guest runtime.
67pub type ResponseFuture = Pin<Box<dyn Future<Output = String> + Send + 'static>>;
68
69/// One parsed invocation and the future producing its raw JSON response.
70pub struct Dispatch {
71    /// Guest-provided correlation identifier.
72    pub id: u64,
73    /// Future resolving to a JSON value string.
74    pub response: ResponseFuture,
75}
76
77/// Failure to parse or route an invocation envelope.
78#[derive(Debug)]
79pub enum DispatchError {
80    /// The message was not a valid invocation JSON object.
81    InvalidInvocation(serde_json::Error),
82    /// No handler was registered for the requested command.
83    UnknownCommand(String),
84}
85
86impl fmt::Display for DispatchError {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match self {
89            Self::InvalidInvocation(error) => write!(f, "invalid HMI invocation: {error}"),
90            Self::UnknownCommand(command) => write!(f, "unknown HMI command: {command}"),
91        }
92    }
93}
94
95impl Error for DispatchError {
96    fn source(&self) -> Option<&(dyn Error + 'static)> {
97        match self {
98            Self::InvalidInvocation(error) => Some(error),
99            Self::UnknownCommand(_) => None,
100        }
101    }
102}
103
104/// Registry of application services exposed to an HMI backend.
105///
106/// `Bridge` contains no runtime or renderer state. It is cheap to clone and
107/// can be attached to WebKit, PocketJS, a headless test host, or another HMI
108/// adapter. Handlers receive a normalized raw JSON payload and return a raw
109/// JSON value string.
110#[derive(Clone, Default)]
111pub struct Bridge {
112    handlers: Arc<Mutex<HashMap<String, Handler>>>,
113}
114
115impl Bridge {
116    /// Creates an empty command registry.
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Registers a synchronous command handler.
122    pub fn handle(
123        &mut self,
124        cmd: impl Into<String>,
125        f: impl Fn(String) -> String + Send + Sync + 'static,
126    ) {
127        self.handlers
128            .lock()
129            .unwrap()
130            .insert(cmd.into(), Handler::sync(f));
131    }
132
133    /// Registers an asynchronous command handler.
134    pub fn handle_async<F, Fut>(&mut self, cmd: impl Into<String>, f: F)
135    where
136        F: Fn(String) -> Fut + Send + Sync + 'static,
137        Fut: Future<Output = String> + Send + 'static,
138    {
139        self.handlers
140            .lock()
141            .unwrap()
142            .insert(cmd.into(), Handler::async_handler(f));
143    }
144
145    /// Starts a command directly, returning `None` when it is not registered.
146    ///
147    /// Calling this method never executes user code. Both synchronous and
148    /// asynchronous handlers begin when the returned future is polled by the
149    /// backend.
150    pub fn invoke(&self, cmd: &str, payload: impl Into<String>) -> Option<ResponseFuture> {
151        let handler = self.handlers.lock().unwrap().get(cmd).cloned()?;
152        Some(handler.call(payload.into()))
153    }
154
155    /// Parses a JSON invocation envelope and routes it to a handler.
156    ///
157    /// The accepted shape is `{ "id": number, "cmd": string, "payload":
158    /// any-json-value }`. Backends use `id` only for response correlation.
159    pub fn dispatch(&self, message: &str) -> Result<Dispatch, DispatchError> {
160        let invocation = parse_invocation(message).map_err(DispatchError::InvalidInvocation)?;
161        let response = self
162            .invoke(&invocation.cmd, invocation.payload.to_string())
163            .ok_or_else(|| DispatchError::UnknownCommand(invocation.cmd.clone()))?;
164        Ok(Dispatch {
165            id: invocation.id,
166            response,
167        })
168    }
169
170    /// Returns whether no commands are registered.
171    pub fn is_empty(&self) -> bool {
172        self.handlers.lock().unwrap().is_empty()
173    }
174
175    /// Returns the number of registered commands.
176    pub fn len(&self) -> usize {
177        self.handlers.lock().unwrap().len()
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn invokes_sync_handler_without_a_runtime_dependency() {
187        let mut bridge = Bridge::new();
188        bridge.handle("echo", |payload| payload);
189
190        let response = bridge.invoke("echo", r#"{"ok":true}"#).unwrap();
191        assert_eq!(pollster(response), r#"{"ok":true}"#);
192    }
193
194    #[test]
195    fn dispatches_json_envelope() {
196        let mut bridge = Bridge::new();
197        bridge.handle_async("answer", |_| async { "42".into() });
198
199        let dispatch = bridge
200            .dispatch(r#"{"id":7,"cmd":"answer","payload":null}"#)
201            .unwrap();
202        assert_eq!(dispatch.id, 7);
203        assert_eq!(pollster(dispatch.response), "42");
204    }
205
206    #[test]
207    fn reports_invalid_and_unknown_invocations() {
208        let bridge = Bridge::new();
209        assert!(matches!(
210            bridge.dispatch("not json"),
211            Err(DispatchError::InvalidInvocation(_))
212        ));
213        assert!(matches!(
214            bridge.dispatch(r#"{"id":1,"cmd":"missing","payload":null}"#),
215            Err(DispatchError::UnknownCommand(command)) if command == "missing"
216        ));
217    }
218
219    fn pollster(mut future: ResponseFuture) -> String {
220        use std::task::{Context, Poll, Waker};
221
222        let mut context = Context::from_waker(Waker::noop());
223        match future.as_mut().poll(&mut context) {
224            Poll::Ready(value) => value,
225            Poll::Pending => panic!("test future unexpectedly pending"),
226        }
227    }
228}