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}