Skip to main content

host_extensions/first_party/
mesh.rs

1//! Mesh extension — durable dissemination and repair over lossy substrates.
2//!
3//! Registers as `window.host.ext.mesh` in the SPA WebView.
4//! Uses the core `__hostCall` bridge (shared pending-promise map).
5
6use std::sync::Arc;
7
8use serde::Deserialize;
9
10use crate::{
11    executor_contract::{MeshControlEnvelope, MeshObjectPolicy},
12    HostExtension,
13};
14
15use super::mesh_runtime::{InMemoryMeshRuntime, MeshRuntime};
16
17pub struct MeshExtension {
18    runtime: Arc<dyn MeshRuntime>,
19    inject_script: String,
20}
21
22impl Default for MeshExtension {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl MeshExtension {
29    pub fn new() -> Self {
30        Self::with_runtime(InMemoryMeshRuntime::new())
31    }
32
33    pub fn with_runtime<R>(runtime: R) -> Self
34    where
35        R: MeshRuntime,
36    {
37        Self::with_shared_runtime(Arc::new(runtime))
38    }
39
40    pub fn with_shared_runtime(runtime: Arc<dyn MeshRuntime>) -> Self {
41        let inject_script = build_mesh_script(runtime.supports_private());
42        Self {
43            runtime,
44            inject_script,
45        }
46    }
47}
48
49impl HostExtension for MeshExtension {
50    fn namespace(&self) -> &str {
51        "mesh"
52    }
53
54    fn channel(&self) -> &str {
55        "hostBridge"
56    }
57
58    fn inject_script(&self) -> &str {
59        &self.inject_script
60    }
61
62    fn handle_message(&self, method: &str, params: &str) -> Option<String> {
63        match method {
64            "meshPublish" => {
65                let request: TopicPayload = serde_json::from_str(params).ok()?;
66                serde_json::to_string(&self.runtime.publish(&request.topic, &request.data_base64))
67                    .ok()
68            }
69            "meshSubscribe" => {
70                let request: TopicOnlyPayload = serde_json::from_str(params).ok()?;
71                serde_json::to_string(&self.runtime.subscribe(&request.topic)).ok()
72            }
73            "meshPutObject" => {
74                let request: PutObjectPayload = serde_json::from_str(params).ok()?;
75                serde_json::to_string(&self.runtime.put_object(
76                    &request.path,
77                    &request.data_base64,
78                    request.policy.unwrap_or_default(),
79                ))
80                .ok()
81            }
82            "meshGetObject" => {
83                let request: PathOnlyPayload = serde_json::from_str(params).ok()?;
84                serde_json::to_string(&self.runtime.get_object(&request.path)).ok()
85            }
86            "meshGetObjectResult" => {
87                let request: PathOnlyPayload = serde_json::from_str(params).ok()?;
88                serde_json::to_string(&self.runtime.get_object_result(&request.path)).ok()
89            }
90            "meshRequest" => {
91                let request: PathPayload = serde_json::from_str(params).ok()?;
92                serde_json::to_string(&self.runtime.request(&request.path, &request.data_base64))
93                    .ok()
94            }
95            "meshRespond" => {
96                let request: ResponsePayload = serde_json::from_str(params).ok()?;
97                serde_json::to_string(
98                    &self
99                        .runtime
100                        .respond(&request.request_id, &request.data_base64),
101                )
102                .ok()
103            }
104            "meshStatus" => serde_json::to_string(&self.runtime.status()).ok(),
105            "meshPrivatePutObject" if self.runtime.supports_private() => {
106                let request: PrivatePutObjectPayload = serde_json::from_str(params).ok()?;
107                serde_json::to_string(
108                    &self.runtime.put_private_object(
109                        &request.data_base64,
110                        request.policy.unwrap_or_default(),
111                    ),
112                )
113                .ok()
114            }
115            "meshPrivateGetObject" if self.runtime.supports_private() => {
116                let request: PrivateGetObjectPayload = serde_json::from_str(params).ok()?;
117                serde_json::to_string(
118                    &self
119                        .runtime
120                        .get_private_object_result(&request.handle, &request.capability),
121                )
122                .ok()
123            }
124            "meshPrivateRequest" if self.runtime.supports_private() => {
125                let request: PrivateRequestPayload = serde_json::from_str(params).ok()?;
126                serde_json::to_string(&self.runtime.request_private(
127                    &request.handle,
128                    &request.capability,
129                    &request.data_base64,
130                ))
131                .ok()
132            }
133            "meshPrivateControlPublish" if self.runtime.supports_private() => {
134                let request: PrivateControlPublishPayload = serde_json::from_str(params).ok()?;
135                serde_json::to_string(
136                    &self
137                        .runtime
138                        .publish_private_control(&request.capability, request.envelope),
139                )
140                .ok()
141            }
142            "meshPrivateControlSubscribe" if self.runtime.supports_private() => {
143                let request: PrivateCapabilityPayload = serde_json::from_str(params).ok()?;
144                serde_json::to_string(&self.runtime.subscribe_private_control(&request.capability))
145                    .ok()
146            }
147            "meshPrivateReceiptPublish" if self.runtime.supports_private() => {
148                let request: PrivateReceiptPublishPayload = serde_json::from_str(params).ok()?;
149                serde_json::to_string(
150                    &self
151                        .runtime
152                        .publish_private_receipt(&request.capability, &request.data_base64),
153                )
154                .ok()
155            }
156            "meshPrivateReceiptSubscribe" if self.runtime.supports_private() => {
157                let request: PrivateCapabilityPayload = serde_json::from_str(params).ok()?;
158                serde_json::to_string(&self.runtime.subscribe_private_receipt(&request.capability))
159                    .ok()
160            }
161            _ => None,
162        }
163    }
164
165    fn drain_events(&self) -> Vec<crate::HostPushEvent> {
166        self.runtime.drain_events()
167    }
168}
169
170fn build_mesh_script(supports_private: bool) -> String {
171    let private_block = if supports_private {
172        r#",
173        private: Object.freeze({
174            objects: Object.freeze({
175                put: function(dataBase64, policy) {
176                    return window.__hostCall('hostBridge', 'meshPrivatePutObject', {
177                        dataBase64: dataBase64,
178                        policy: policy
179                    });
180                },
181                get: function(handle, capability) {
182                    return window.__hostCall('hostBridge', 'meshPrivateGetObject', {
183                        handle: handle,
184                        capability: capability
185                    });
186                }
187            }),
188            query: Object.freeze({
189                request: function(handle, capability, dataBase64) {
190                    return window.__hostCall('hostBridge', 'meshPrivateRequest', {
191                        handle: handle,
192                        capability: capability,
193                        dataBase64: dataBase64
194                    });
195                }
196            }),
197            control: Object.freeze({
198                publish: function(capability, envelope) {
199                    return window.__hostCall('hostBridge', 'meshPrivateControlPublish', {
200                        capability: capability,
201                        envelope: envelope
202                    });
203                },
204                subscribe: function(capability) {
205                    return window.__hostCall('hostBridge', 'meshPrivateControlSubscribe', {
206                        capability: capability
207                    });
208                }
209            }),
210            receipts: Object.freeze({
211                publish: function(capability, dataBase64) {
212                    return window.__hostCall('hostBridge', 'meshPrivateReceiptPublish', {
213                        capability: capability,
214                        dataBase64: dataBase64
215                    });
216                },
217                subscribe: function(capability) {
218                    return window.__hostCall('hostBridge', 'meshPrivateReceiptSubscribe', {
219                        capability: capability
220                    });
221                }
222            })
223        })"#
224    } else {
225        ""
226    };
227
228    format!(
229        r#"
230(function(){{
231    if (!window.host || !window.host.ext) return;
232    window.host.ext.mesh = Object.freeze({{
233        transport: Object.freeze({{
234            publish: function(topic, dataBase64) {{
235                return window.__hostCall('hostBridge', 'meshPublish', {{
236                    topic: topic,
237                    dataBase64: dataBase64
238                }});
239            }},
240            subscribe: function(topic) {{
241                return window.__hostCall('hostBridge', 'meshSubscribe', {{
242                    topic: topic
243                }});
244            }}
245        }}),
246        objects: Object.freeze({{
247            put: function(path, dataBase64, policy) {{
248                return window.__hostCall('hostBridge', 'meshPutObject', {{
249                    path: path,
250                    dataBase64: dataBase64,
251                    policy: policy
252                }});
253            }},
254            get: function(path) {{
255                return window.__hostCall('hostBridge', 'meshGetObject', {{
256                    path: path
257                }});
258            }},
259            getResult: function(path) {{
260                return window.__hostCall('hostBridge', 'meshGetObjectResult', {{
261                    path: path
262                }});
263            }}
264        }}),
265        query: Object.freeze({{
266            request: function(path, dataBase64) {{
267                return window.__hostCall('hostBridge', 'meshRequest', {{
268                    path: path,
269                    dataBase64: dataBase64
270                }});
271            }},
272            respond: function(requestId, dataBase64) {{
273                return window.__hostCall('hostBridge', 'meshRespond', {{
274                    requestId: requestId,
275                    dataBase64: dataBase64
276                }});
277            }}
278        }}),
279        status: Object.freeze({{
280            status: function() {{
281                return window.__hostCall('hostBridge', 'meshStatus', {{}});
282            }}
283        }}){private_block}
284    }});
285}})();
286"#
287    )
288}
289
290#[derive(Deserialize)]
291#[serde(rename_all = "camelCase")]
292struct TopicPayload {
293    topic: String,
294    data_base64: String,
295}
296
297#[derive(Deserialize)]
298#[serde(rename_all = "camelCase")]
299struct TopicOnlyPayload {
300    topic: String,
301}
302
303#[derive(Deserialize)]
304#[serde(rename_all = "camelCase")]
305struct PathPayload {
306    path: String,
307    data_base64: String,
308}
309
310#[derive(Deserialize)]
311#[serde(rename_all = "camelCase")]
312struct PutObjectPayload {
313    path: String,
314    data_base64: String,
315    #[serde(default)]
316    policy: Option<MeshObjectPolicy>,
317}
318
319#[derive(Deserialize)]
320#[serde(rename_all = "camelCase")]
321struct PathOnlyPayload {
322    path: String,
323}
324
325#[derive(Deserialize)]
326#[serde(rename_all = "camelCase")]
327struct ResponsePayload {
328    request_id: String,
329    data_base64: String,
330}
331
332#[derive(Deserialize)]
333#[serde(rename_all = "camelCase")]
334struct PrivatePutObjectPayload {
335    data_base64: String,
336    #[serde(default)]
337    policy: Option<MeshObjectPolicy>,
338}
339
340#[derive(Deserialize)]
341#[serde(rename_all = "camelCase")]
342struct PrivateGetObjectPayload {
343    handle: String,
344    capability: String,
345}
346
347#[derive(Deserialize)]
348#[serde(rename_all = "camelCase")]
349struct PrivateRequestPayload {
350    handle: String,
351    capability: String,
352    data_base64: String,
353}
354
355#[derive(Deserialize)]
356#[serde(rename_all = "camelCase")]
357struct PrivateCapabilityPayload {
358    capability: String,
359}
360
361#[derive(Deserialize)]
362#[serde(rename_all = "camelCase")]
363struct PrivateControlPublishPayload {
364    capability: String,
365    envelope: MeshControlEnvelope,
366}
367
368#[derive(Deserialize)]
369#[serde(rename_all = "camelCase")]
370struct PrivateReceiptPublishPayload {
371    capability: String,
372    data_base64: String,
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::executor_contract::{
379        MeshControlEnvelope, MeshControlMode, MeshObjectPolicy, MeshObjectReadReason,
380        MeshObjectResult, MeshPrivateObjectRef, MeshStatusResult,
381    };
382    use crate::first_party::mesh_runtime::MeshRuntime;
383
384    struct StaticStatusRuntime;
385
386    impl MeshRuntime for StaticStatusRuntime {
387        fn publish(&self, _topic: &str, _data_base64: &str) -> bool {
388            true
389        }
390
391        fn subscribe(&self, _topic: &str) -> bool {
392            true
393        }
394
395        fn put_object(&self, _path: &str, _data_base64: &str, _policy: MeshObjectPolicy) -> bool {
396            true
397        }
398
399        fn get_object_result(&self, _path: &str) -> MeshObjectResult {
400            MeshObjectResult::unavailable(MeshObjectReadReason::NotFound, None)
401        }
402
403        fn request(&self, _path: &str, _data_base64: &str) -> String {
404            "mesh-req-static".into()
405        }
406
407        fn respond(&self, _request_id: &str, _data_base64: &str) -> bool {
408            true
409        }
410
411        fn status(&self) -> MeshStatusResult {
412            MeshStatusResult {
413                health: "degraded".into(),
414                transport: "custom-runtime".into(),
415                pending_publishes: 2,
416                pending_queries: 3,
417                last_error: Some("submit failed".into()),
418            }
419        }
420    }
421
422    struct PrivateStaticRuntime;
423
424    impl MeshRuntime for PrivateStaticRuntime {
425        fn publish(&self, _topic: &str, _data_base64: &str) -> bool {
426            true
427        }
428
429        fn subscribe(&self, _topic: &str) -> bool {
430            true
431        }
432
433        fn put_object(&self, _path: &str, _data_base64: &str, _policy: MeshObjectPolicy) -> bool {
434            true
435        }
436
437        fn get_object_result(&self, _path: &str) -> MeshObjectResult {
438            MeshObjectResult::unavailable(MeshObjectReadReason::NotFound, None)
439        }
440
441        fn request(&self, _path: &str, _data_base64: &str) -> String {
442            "mesh-req-private".into()
443        }
444
445        fn respond(&self, _request_id: &str, _data_base64: &str) -> bool {
446            true
447        }
448
449        fn status(&self) -> MeshStatusResult {
450            MeshStatusResult {
451                health: "healthy".into(),
452                transport: "custom-runtime".into(),
453                pending_publishes: 0,
454                pending_queries: 0,
455                last_error: None,
456            }
457        }
458
459        fn supports_private(&self) -> bool {
460            true
461        }
462
463        fn put_private_object(
464            &self,
465            _data_base64: &str,
466            policy: MeshObjectPolicy,
467        ) -> MeshPrivateObjectRef {
468            MeshPrivateObjectRef {
469                handle: "mesh-private-handle-1".into(),
470                capability: "mesh-private-capability-1".into(),
471                expires_at_ms: policy.expires_at_ms,
472            }
473        }
474
475        fn get_private_object_result(&self, _handle: &str, _capability: &str) -> MeshObjectResult {
476            MeshObjectResult::found("AQID".into(), Some(1710000000000))
477        }
478
479        fn request_private(&self, _handle: &str, _capability: &str, _data_base64: &str) -> String {
480            "mesh-private-req-1".into()
481        }
482
483        fn publish_private_control(
484            &self,
485            _capability: &str,
486            _envelope: MeshControlEnvelope,
487        ) -> bool {
488            true
489        }
490
491        fn subscribe_private_control(&self, _capability: &str) -> bool {
492            true
493        }
494
495        fn publish_private_receipt(&self, _capability: &str, _data_base64: &str) -> bool {
496            true
497        }
498
499        fn subscribe_private_receipt(&self, _capability: &str) -> bool {
500            true
501        }
502    }
503
504    #[test]
505    fn mesh_extension_basics() {
506        let ext = MeshExtension::new();
507        assert_eq!(ext.namespace(), "mesh");
508        assert!(ext.inject_script().contains("window.host.ext.mesh"));
509        assert!(ext.inject_script().contains("meshPublish"));
510        assert!(ext.inject_script().contains("meshPutObject"));
511        assert!(ext.inject_script().contains("meshGetObjectResult"));
512        assert!(ext.inject_script().contains("meshRequest"));
513        assert!(ext.inject_script().contains("meshStatus"));
514        assert!(ext.inject_script().contains("meshPrivatePutObject"));
515    }
516
517    #[test]
518    fn mesh_extension_handles_core_methods() {
519        let ext = MeshExtension::new();
520
521        assert_eq!(
522            ext.handle_message("meshPublish", r#"{"topic":"room/1","dataBase64":"AQID"}"#),
523            Some("true".to_string())
524        );
525        assert_eq!(
526            ext.handle_message("meshSubscribe", r#"{"topic":"room/1"}"#),
527            Some("true".to_string())
528        );
529        assert_eq!(
530            ext.handle_message(
531                "meshPutObject",
532                r#"{"path":"mesh/object/1","dataBase64":"AQID","policy":{"expiresAtMs":18446744073709551615,"suppressPreviews":true}}"#
533            ),
534            Some("true".to_string())
535        );
536        assert_eq!(
537            ext.handle_message("meshGetObject", r#"{"path":"mesh/object/1"}"#),
538            Some(r#""AQID""#.to_string())
539        );
540        assert_eq!(
541            ext.handle_message("meshGetObjectResult", r#"{"path":"mesh/object/1"}"#),
542            Some(r#"{"dataBase64":"AQID","expiresAtMs":18446744073709551615}"#.to_string())
543        );
544
545        let request_id = ext
546            .handle_message(
547                "meshRequest",
548                r#"{"path":"mesh/object/1","dataBase64":"AQID"}"#,
549            )
550            .expect("meshRequest result");
551        assert!(request_id.contains("mesh-req-"));
552        // Cache-hit: request resolves immediately with a meshReply event.
553        let events = ext.drain_events();
554        assert_eq!(events.len(), 1, "expected meshReply event on cache hit");
555        assert_eq!(events[0].event, "meshReply");
556        assert!(events[0].payload_json.contains("AQID"));
557        // Responding to an already-resolved request returns false.
558        assert_eq!(
559            ext.handle_message(
560                "meshRespond",
561                &format!(r#"{{"requestId":{},"dataBase64":"AQID"}}"#, request_id)
562            ),
563            Some("false".to_string())
564        );
565
566        let status_json = ext
567            .handle_message("meshStatus", r#"{}"#)
568            .expect("meshStatus result");
569        let status: MeshStatusResult = serde_json::from_str(&status_json).expect("status json");
570        assert_eq!(status.health, "healthy");
571        assert_eq!(status.transport, "in-memory");
572    }
573
574    #[test]
575    fn mesh_extension_supports_injected_runtime() {
576        let ext = MeshExtension::with_runtime(StaticStatusRuntime);
577        let status_json = ext
578            .handle_message("meshStatus", r#"{}"#)
579            .expect("meshStatus result");
580        let status: MeshStatusResult = serde_json::from_str(&status_json).expect("status json");
581
582        assert_eq!(status.health, "degraded");
583        assert_eq!(status.transport, "custom-runtime");
584        assert_eq!(status.pending_publishes, 2);
585        assert_eq!(status.pending_queries, 3);
586        assert_eq!(status.last_error.as_deref(), Some("submit failed"));
587    }
588
589    #[test]
590    fn mesh_extension_hides_private_surface_when_runtime_does_not_support_it() {
591        let ext = MeshExtension::with_runtime(StaticStatusRuntime);
592        // Script should NOT contain private methods
593        assert!(!ext.inject_script().contains("meshPrivatePutObject"));
594        assert!(!ext.inject_script().contains("meshPrivateControlPublish"));
595        assert!(!ext.inject_script().contains("meshPrivateReceiptSubscribe"));
596        assert!(!ext.inject_script().contains("private:"));
597
598        // All private handle_message calls should return None
599        assert_eq!(
600            ext.handle_message("meshPrivatePutObject", r#"{"dataBase64":"AQID"}"#,),
601            None
602        );
603        assert_eq!(
604            ext.handle_message("meshPrivateGetObject", r#"{"handle":"h","capability":"c"}"#,),
605            None
606        );
607        assert_eq!(
608            ext.handle_message(
609                "meshPrivateRequest",
610                r#"{"handle":"h","capability":"c","dataBase64":""}"#,
611            ),
612            None
613        );
614        assert_eq!(
615            ext.handle_message(
616                "meshPrivateControlPublish",
617                r#"{"capability":"c","envelope":{"mode":"encrypted","dataBase64":"AQID"}}"#,
618            ),
619            None
620        );
621        assert_eq!(
622            ext.handle_message("meshPrivateControlSubscribe", r#"{"capability":"c"}"#,),
623            None
624        );
625        assert_eq!(
626            ext.handle_message(
627                "meshPrivateReceiptPublish",
628                r#"{"capability":"c","dataBase64":"AQID"}"#,
629            ),
630            None
631        );
632        assert_eq!(
633            ext.handle_message("meshPrivateReceiptSubscribe", r#"{"capability":"c"}"#,),
634            None
635        );
636
637        // Public mesh methods should still work
638        assert_eq!(
639            ext.handle_message("meshPublish", r#"{"topic":"room/1","dataBase64":"AQID"}"#),
640            Some("true".to_string())
641        );
642    }
643
644    #[test]
645    fn mesh_extension_includes_private_surface_when_runtime_supports_it() {
646        let ext = MeshExtension::with_runtime(PrivateStaticRuntime);
647        assert!(ext.inject_script().contains("meshPrivatePutObject"));
648        assert!(ext.inject_script().contains("meshPrivateControlPublish"));
649        assert!(ext.inject_script().contains("meshPrivateReceiptSubscribe"));
650
651        assert_eq!(
652            ext.handle_message(
653                "meshPrivatePutObject",
654                r#"{"dataBase64":"AQID","policy":{"expiresAtMs":1710000000000}}"#,
655            ),
656            Some(r#"{"handle":"mesh-private-handle-1","capability":"mesh-private-capability-1","expiresAtMs":1710000000000}"#.to_string())
657        );
658        assert_eq!(
659            ext.handle_message(
660                "meshPrivateGetObject",
661                r#"{"handle":"mesh-private-handle-1","capability":"mesh-private-capability-1"}"#,
662            ),
663            Some(r#"{"dataBase64":"AQID","expiresAtMs":1710000000000}"#.to_string())
664        );
665        assert_eq!(
666            ext.handle_message(
667                "meshPrivateRequest",
668                r#"{"handle":"mesh-private-handle-1","capability":"mesh-private-capability-1","dataBase64":""}"#,
669            ),
670            Some(r#""mesh-private-req-1""#.to_string())
671        );
672        assert_eq!(
673            ext.handle_message(
674                "meshPrivateControlPublish",
675                r#"{"capability":"mesh-private-capability-1","envelope":{"mode":"encrypted","dataBase64":"AQID"}}"#,
676            ),
677            Some("true".to_string())
678        );
679        assert_eq!(
680            ext.handle_message(
681                "meshPrivateControlSubscribe",
682                r#"{"capability":"mesh-private-capability-1"}"#,
683            ),
684            Some("true".to_string())
685        );
686        assert_eq!(
687            ext.handle_message(
688                "meshPrivateReceiptPublish",
689                r#"{"capability":"mesh-private-capability-1","dataBase64":"AQID"}"#,
690            ),
691            Some("true".to_string())
692        );
693        assert_eq!(
694            ext.handle_message(
695                "meshPrivateReceiptSubscribe",
696                r#"{"capability":"mesh-private-capability-1"}"#,
697            ),
698            Some("true".to_string())
699        );
700
701        let _ = MeshControlEnvelope {
702            mode: MeshControlMode::Visible,
703            data_base64: "AQID".into(),
704        };
705    }
706}