Skip to main content

eui_core/host/
router.rs

1//! The host router. Server messages in, ops out (`catalog/host.json` ops);
2//! the platform host performs them on its surfaces. Pure and synchronous, so
3//! `fixtures/host-router.json` locks the same behaviour on every platform.
4
5use indexmap::IndexMap;
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Map, Value};
8
9use super::contract::{error_message, INVALID_MESSAGE, MESSAGE_KINDS, SOURCE_DENIED, SURFACE_NOT_FOUND, TEMPLATE_NOT_FOUND, UNSUPPORTED_CATALOG};
10use super::package::{package_sources, source_allowed, supported_catalog_ids, template_messages, validate_package_with, AvailableExtension, PackageIssue};
11use super::sources::parse_source;
12use crate::catalog::A2UI_VERSION;
13use crate::limits;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct SurfaceInfo {
18    pub surface_id: String,
19    pub catalog_id: String,
20    /// The package whose template created it (its function policy).
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub package_id: Option<String>,
23}
24
25/// One router per connection: the surfaces it created, the packages
26/// installed, the extension catalogs negotiated.
27#[derive(Debug, Clone, Default)]
28pub struct HostRouter {
29    extension_ids: Vec<String>,
30    /// VAPP-161: their versions and components (a package's `extensions`
31    /// are checked against them; `None` = only that the id is registered).
32    extensions: Option<Vec<AvailableExtension>>,
33    surfaces: IndexMap<String, SurfaceInfo>,
34    packages: IndexMap<String, Value>,
35}
36
37fn send(message: Value) -> Value {
38    json!({"op": "send", "message": message})
39}
40
41fn invalid(surface_id: &str, message: &str) -> Value {
42    send(error_message(INVALID_MESSAGE, surface_id, message, None))
43}
44
45fn missing(surface_id: &str) -> Value {
46    send(error_message(SURFACE_NOT_FOUND, surface_id, &format!("no surface {surface_id}; send createSurface first"), None))
47}
48
49/// Is the message's JSON past `maxMessageBytes`? (Counted while
50/// serializing; the count stops at the limit.)
51fn oversized(message: &Value) -> bool {
52    struct Count(usize);
53    impl std::io::Write for Count {
54        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
55            self.0 += buf.len();
56            if self.0 > limits::MAX_MESSAGE_BYTES {
57                return Err(std::io::Error::other("over the limit"));
58            }
59            Ok(buf.len())
60        }
61        fn flush(&mut self) -> std::io::Result<()> {
62            Ok(())
63        }
64    }
65    let mut count = Count(0);
66    serde_json::to_writer(&mut count, message).is_err()
67}
68
69fn surface_id_of(message: &Map<String, Value>) -> String {
70    message.values().find_map(|v| v.as_object().and_then(|o| o.get("surfaceId")).and_then(Value::as_str)).unwrap_or("").to_string()
71}
72
73/// A2UI: an omitted path or `/` is the whole data model (`""` in JSON pointer
74/// terms, which every surface's setPointer takes).
75pub fn normalize_path(path: Option<&str>) -> String {
76    match path {
77        None | Some("/") => String::new(),
78        Some(p) => p.to_string(),
79    }
80}
81
82impl HostRouter {
83    /// `extension_ids` = the extension catalog ids the host registered.
84    pub fn new<S: AsRef<str>>(extension_ids: &[S]) -> Self {
85        HostRouter { extension_ids: extension_ids.iter().map(|s| s.as_ref().to_string()).collect(), ..Default::default() }
86    }
87
88    /// [`Self::new`] with the registered extensions' versions and components
89    /// (the TS `HostRouterOptions.extensions`).
90    pub fn with_extensions<S: AsRef<str>>(extension_ids: &[S], extensions: Option<Vec<AvailableExtension>>) -> Self {
91        HostRouter { extensions, ..Self::new(extension_ids) }
92    }
93
94    pub fn supported_catalog_ids(&self) -> Vec<String> {
95        supported_catalog_ids(&self.extension_ids)
96    }
97
98    pub fn register_extension(&mut self, id: &str) {
99        self.register_extension_with(id, None);
100    }
101
102    /// VAPP-161: with its version and components (`None` = `{id}`), kept
103    /// when the router checks extensions at all.
104    pub fn register_extension_with(&mut self, id: &str, available: Option<AvailableExtension>) {
105        if !self.extension_ids.iter().any(|x| x == id) {
106            self.extension_ids.push(id.to_string());
107        }
108        if let Some(extensions) = &mut self.extensions {
109            if !extensions.iter().any(|e| e.id == id) {
110                extensions.push(available.unwrap_or_else(|| AvailableExtension { id: id.to_string(), ..Default::default() }));
111            }
112        }
113    }
114
115    /// Install a declarative package: its templates become `applyTemplate`
116    /// targets. Returns the validation issues (installed only when none).
117    pub fn install_package(&mut self, pkg: &Value) -> Vec<PackageIssue> {
118        let issues = validate_package_with(pkg, &self.supported_catalog_ids(), self.extensions.as_deref());
119        if issues.is_empty() {
120            let id = pkg["id"].as_str().unwrap_or("").to_string();
121            self.packages.insert(id, pkg.clone());
122        }
123        issues
124    }
125
126    pub fn package(&self, id: &str) -> Option<&Value> {
127        self.packages.get(id)
128    }
129
130    pub fn surface(&self, id: &str) -> Option<&SurfaceInfo> {
131        self.surfaces.get(id)
132    }
133
134    /// In creation order.
135    pub fn surface_ids(&self) -> Vec<String> {
136        self.surfaces.keys().cloned().collect()
137    }
138
139    /// The package whose template created the surface.
140    pub fn package_id_of(&self, surface_id: &str) -> Option<String> {
141        self.surfaces.get(surface_id).and_then(|s| s.package_id.clone())
142    }
143
144    /// One server message → the ops to perform, in order. Never fails.
145    /// VAPP-103: a message past `maxMessageBytes` (as UTF-8 JSON) is
146    /// refused: INVALID_MESSAGE.
147    pub fn route(&mut self, message: &Value) -> Vec<Value> {
148        if oversized(message) {
149            let sid = message.as_object().map(surface_id_of).unwrap_or_default();
150            return vec![invalid(&sid, &limits::message_bytes_issue())];
151        }
152        let Some(message) = message.as_object() else { return vec![invalid("", "a message is a JSON object")] };
153        if let Some(version) = message.get("version") {
154            if version.as_str() != Some(A2UI_VERSION) {
155                return vec![invalid(&surface_id_of(message), &format!("unsupported version {}", crate::json::to_js_string(version)))];
156            }
157        }
158        let kinds: Vec<&String> = message.keys().filter(|k| *k != "version").collect();
159        if kinds.len() != 1 || !MESSAGE_KINDS.contains(&kinds[0].as_str()) {
160            return vec![invalid(&surface_id_of(message), &format!("expected exactly one of {}", MESSAGE_KINDS.join(", ")))];
161        }
162        let kind = kinds[0].as_str();
163        let Some(body) = message[kind].as_object().filter(|b| b.get("surfaceId").and_then(Value::as_str).is_some_and(|s| !s.is_empty())) else {
164            return vec![invalid("", &format!("{kind}.surfaceId is required"))];
165        };
166        let surface_id = body["surfaceId"].as_str().unwrap_or("").to_string();
167        let sid = surface_id.as_str();
168        match kind {
169            "createSurface" => {
170                let Some(catalog_id) = body.get("catalogId").and_then(Value::as_str) else {
171                    return vec![invalid(sid, "createSurface.catalogId is required")];
172                };
173                if !self.supported_catalog_ids().iter().any(|c| c == catalog_id) {
174                    return vec![send(error_message(UNSUPPORTED_CATALOG, sid, &format!("catalog {catalog_id} is not supported"), None))];
175                }
176                self.surfaces.insert(surface_id.clone(), SurfaceInfo { surface_id: surface_id.clone(), catalog_id: catalog_id.to_string(), package_id: None });
177                let mut op = Map::new();
178                op.insert("op".into(), json!("create"));
179                op.insert("surfaceId".into(), json!(sid));
180                op.insert("catalogId".into(), json!(catalog_id));
181                if let Some(theme) = body.get("theme") {
182                    op.insert("theme".into(), theme.clone());
183                }
184                if body.get("sendDataModel") == Some(&Value::Bool(true)) {
185                    op.insert("sendDataModel".into(), Value::Bool(true));
186                }
187                vec![Value::Object(op)]
188            }
189            "updateComponents" => {
190                let Some(components) = body.get("components").filter(|c| c.is_array()) else {
191                    return vec![invalid(sid, "updateComponents.components is required")];
192                };
193                if !self.surfaces.contains_key(sid) {
194                    return vec![missing(sid)];
195                }
196                vec![json!({"op": "components", "surfaceId": sid, "components": components})]
197            }
198            "updateDataModel" => {
199                if !self.surfaces.contains_key(sid) {
200                    return vec![missing(sid)];
201                }
202                let path = match body.get("path") {
203                    None => None,
204                    Some(Value::String(p)) => Some(p.as_str()),
205                    Some(_) => return vec![invalid(sid, "updateDataModel.path is a string")],
206                };
207                let path = normalize_path(path);
208                match body.get("value") {
209                    Some(value) => vec![json!({"op": "data", "surfaceId": sid, "path": path, "value": value})],
210                    None => vec![json!({"op": "data", "surfaceId": sid, "path": path})],
211                }
212            }
213            "deleteSurface" => {
214                if self.surfaces.shift_remove(sid).is_none() {
215                    return vec![missing(sid)];
216                }
217                vec![json!({"op": "delete", "surfaceId": sid})]
218            }
219            "bindDataModel" => {
220                let Some(info) = self.surfaces.get(sid) else {
221                    return vec![missing(sid)];
222                };
223                match (body.get("path").and_then(Value::as_str), body.get("source").and_then(Value::as_str)) {
224                    (Some(path), Some(source)) if parse_source(source).is_some() => {
225                        // VAPP-107: a package surface follows only the sources its package lists.
226                        if let Some(pkg) = info.package_id.as_deref().and_then(|id| self.packages.get(id)) {
227                            if !source_allowed(package_sources(pkg).as_deref(), source) {
228                                let pkg_id = pkg.get("id").and_then(Value::as_str).unwrap_or("");
229                                let at = normalize_path(Some(path));
230                                let at = if at.is_empty() { "/".to_string() } else { at };
231                                return vec![send(error_message(SOURCE_DENIED, sid, &format!("source {source} is not in package {pkg_id}'s sources"), Some(&at)))];
232                            }
233                        }
234                        // VAPP-190: `?query=<name>` follows the package's named query.
235                        let source = match info.package_id.as_deref().and_then(|id| self.packages.get(id)) {
236                            Some(pkg) => super::query::expand_query_source(source, pkg.get("queries")),
237                            None => source.to_string(),
238                        };
239                        vec![json!({"op": "bind", "surfaceId": sid, "path": normalize_path(Some(path)), "source": source})]
240                    }
241                    _ => vec![invalid(sid, "bindDataModel needs a path and a scheme:name source")],
242                }
243            }
244            "applyTemplate" => {
245                let Some(template_id) = body.get("templateId").and_then(Value::as_str) else {
246                    return vec![invalid(sid, "applyTemplate.templateId is required")];
247                };
248                let Some(pkg) = self.find_package(template_id, body.get("packageId").and_then(Value::as_str)).cloned() else {
249                    return vec![send(error_message(TEMPLATE_NOT_FOUND, sid, &format!("no installed package has the template {template_id}"), None))];
250                };
251                let mut ops = Vec::new();
252                let pkg_id = pkg.get("id").and_then(Value::as_str).map(str::to_string);
253                for m in template_messages(&pkg, template_id, sid, body.get("data")).unwrap_or_default() {
254                    ops.extend(self.route(&m));
255                    // The surface belongs to the package from its creation on (its
256                    // function policy and source allowlist apply to the template's own binds).
257                    if let Some(info) = self.surfaces.get_mut(sid) {
258                        info.package_id = pkg_id.clone();
259                    }
260                }
261                ops
262            }
263            _ => vec![invalid(sid, "unknown message")],
264        }
265    }
266
267    fn find_package(&self, template_id: &str, package_id: Option<&str>) -> Option<&Value> {
268        let has = |pkg: &Value| pkg.get("templates").and_then(|t| t.get(template_id)).is_some_and(super::js_truthy);
269        match package_id {
270            Some(id) => self.packages.get(id).filter(|p| has(p)),
271            None => self.packages.values().find(|p| has(p)),
272        }
273    }
274}