Skip to main content

sim_lib_web_bridge/
session.rs

1//! The session: the Intent/Scene bus with per-pane subscriptions.
2//!
3//! A session ties panes to resources over a [`Transport`]. Opening a value
4//! renders its Scene and subscribes the pane; submitting an Intent decodes it
5//! through the pane's editor, commits the operation through `realize`, and the
6//! transport records a change; pumping re-renders only the affected panes and
7//! returns a Scene diff (from P1) for each. The session never speaks a
8//! transport-specific API.
9
10use sim_kernel::{Cx, Error, Expr, Result, Symbol};
11use sim_lib_view::{LensRegistry, Mode, universal_scene};
12
13use crate::transport::{SessionStatus, Transport};
14
15/// The largest number of distinct panes one session may hold at once. Opening
16/// beyond this is refused: untrusted `pane` query values must not grow the
17/// per-pane work [`Session::pump`] does on every event without bound.
18const MAX_PANES: usize = 64;
19
20/// The largest accepted pane-name length, bounding an untrusted `pane` value.
21const MAX_PANE_NAME: usize = 128;
22
23/// The largest accepted resource-name length, bounding an untrusted `resource`
24/// value.
25const MAX_RESOURCE_NAME: usize = 512;
26
27/// Reject a pane name that is empty, over-long, or not printable ASCII (the
28/// `pane` query param is untrusted).
29fn validate_pane_name(pane: &Symbol) -> Result<()> {
30    let name = pane.as_qualified_str();
31    if name.is_empty() || name.len() > MAX_PANE_NAME {
32        return Err(Error::HostError(format!(
33            "pane name must be 1..={MAX_PANE_NAME} bytes, got {}",
34            name.len()
35        )));
36    }
37    if !name.bytes().all(|byte| byte.is_ascii_graphic()) {
38        return Err(Error::HostError(
39            "pane name must be printable ASCII without spaces".to_owned(),
40        ));
41    }
42    Ok(())
43}
44
45/// Reject a resource name that is empty or over-long (the `resource` query
46/// param is untrusted). Charset stays lenient; an unknown resource fails the
47/// transport read anyway.
48fn validate_resource_name(resource: &Symbol) -> Result<()> {
49    let name = resource.as_qualified_str();
50    if name.is_empty() || name.len() > MAX_RESOURCE_NAME {
51        return Err(Error::HostError(format!(
52            "resource name must be 1..={MAX_RESOURCE_NAME} bytes, got {}",
53            name.len()
54        )));
55    }
56    Ok(())
57}
58
59/// A live binding of a pane to a resource and its lenses.
60struct Subscription {
61    pane: Symbol,
62    resource: Symbol,
63    view_lens: Symbol,
64    editor_lens: Symbol,
65    last_scene: Expr,
66}
67
68/// A re-rendered Scene for a pane, with the diff from its previous Scene.
69#[derive(Clone, Debug)]
70pub struct SceneUpdate {
71    /// The pane that updated.
72    pub pane: Symbol,
73    /// The full new Scene.
74    pub scene: Expr,
75    /// The diff from the previous Scene (a `scene/patch` value).
76    pub diff: Expr,
77}
78
79/// A session over a transport, with per-pane subscriptions and an experience
80/// mode. The mode is session state (a value); switching it never changes the
81/// values being shown.
82pub struct Session<T: Transport> {
83    transport: T,
84    subscriptions: Vec<Subscription>,
85    mode: Mode,
86}
87
88impl<T: Transport> Session<T> {
89    /// Start a session over `transport` in Builder mode.
90    pub fn new(transport: T) -> Self {
91        Self {
92            transport,
93            subscriptions: Vec::new(),
94            mode: Mode::Builder,
95        }
96    }
97
98    /// The visible connection status.
99    pub fn status(&self) -> SessionStatus {
100        self.transport.status()
101    }
102
103    /// The active experience mode.
104    pub fn mode(&self) -> Mode {
105        self.mode
106    }
107
108    /// Handle an `intent/set-mode`, switching the session mode. The values being
109    /// shown are never read or written.
110    pub fn set_mode(&mut self, intent: &Expr) -> Result<()> {
111        match sim_value::access::field(intent, "kind") {
112            Some(Expr::Symbol(kind)) if &*kind.name == "set-mode" => {}
113            _ => {
114                return Err(Error::HostError(
115                    "set_mode expects an intent/set-mode".to_owned(),
116                ));
117            }
118        }
119        let mode = match sim_value::access::field(intent, "mode") {
120            Some(Expr::Symbol(symbol)) => Mode::from_name(&symbol.name),
121            _ => None,
122        };
123        self.mode = mode.ok_or_else(|| {
124            Error::HostError(
125                "intent/set-mode 'mode' must be household, builder, or systems".to_owned(),
126            )
127        })?;
128        Ok(())
129    }
130
131    /// Render a value through the universal default lens at the session's mode
132    /// depth (Household/Builder/Systems show progressively more).
133    pub fn render_universal(&self, value: &Expr) -> Expr {
134        universal_scene(value, self.mode)
135    }
136
137    /// Mutable access to the transport (for example to simulate disconnect in
138    /// tests, or to drive reconnection).
139    pub fn transport_mut(&mut self) -> &mut T {
140        &mut self.transport
141    }
142
143    /// Open `resource` into `pane` with the given view and editor lenses; render
144    /// and subscribe. Returns the initial Scene.
145    pub fn open(
146        &mut self,
147        cx: &mut Cx,
148        registry: &LensRegistry,
149        pane: Symbol,
150        resource: Symbol,
151        view_lens: Symbol,
152        editor_lens: Symbol,
153    ) -> Result<Expr> {
154        validate_pane_name(&pane)?;
155        validate_resource_name(&resource)?;
156        // Opening a brand-new pane (not re-opening an existing one) must not push
157        // the session past its pane cap.
158        let replacing = self.subscriptions.iter().any(|sub| sub.pane == pane);
159        if !replacing && self.subscriptions.len() >= MAX_PANES {
160            return Err(Error::HostError(format!(
161                "session is at its pane limit ({MAX_PANES}); close a pane before opening another"
162            )));
163        }
164        let value = self.transport.read(&resource)?;
165        let scene = registry.render(cx, &view_lens, &value)?;
166        self.subscriptions.retain(|sub| sub.pane != pane);
167        self.subscriptions.push(Subscription {
168            pane,
169            resource,
170            view_lens,
171            editor_lens,
172            last_scene: scene.clone(),
173        });
174        Ok(scene)
175    }
176
177    /// Submit an Intent against the value shown in `pane`: decode through the
178    /// pane's editor and commit the operation through `realize`.
179    pub fn submit_intent(
180        &mut self,
181        cx: &mut Cx,
182        registry: &LensRegistry,
183        pane: &Symbol,
184        intent: &Expr,
185    ) -> Result<()> {
186        let (resource, editor) = {
187            let sub = self
188                .subscriptions
189                .iter()
190                .find(|sub| &sub.pane == pane)
191                .ok_or_else(|| Error::HostError(format!("pane '{pane}' is not open")))?;
192            (sub.resource.clone(), sub.editor_lens.clone())
193        };
194        let value = self.transport.read(&resource)?;
195        let draft = registry.propose(cx, &editor, &value, intent)?;
196        let operation = registry.commit(cx, &editor, &draft)?;
197        self.transport.realize(&resource, &operation.form)?;
198        Ok(())
199    }
200
201    /// Drain pending changes and re-render only the affected panes, returning a
202    /// Scene update (with diff) for each.
203    pub fn pump(&mut self, cx: &mut Cx, registry: &LensRegistry) -> Result<Vec<SceneUpdate>> {
204        let events = self.transport.drain_events();
205        let mut updates = Vec::new();
206        let Self {
207            transport,
208            subscriptions,
209            ..
210        } = self;
211        for event in events {
212            for sub in subscriptions
213                .iter_mut()
214                .filter(|sub| sub.resource == event.resource)
215            {
216                let value = transport.read(&sub.resource)?;
217                let scene = registry.render(cx, &sub.view_lens, &value)?;
218                let diff = sim_lib_scene::diff(&sub.last_scene, &scene);
219                sub.last_scene = scene.clone();
220                updates.push(SceneUpdate {
221                    pane: sub.pane.clone(),
222                    scene,
223                    diff,
224                });
225            }
226        }
227        Ok(updates)
228    }
229}
230
231#[cfg(test)]
232mod tests {
233
234    use sim_kernel::{Cx, Expr, Symbol};
235    use sim_lib_view::{
236        LensRegistry, UNIVERSAL_EDITOR_ID, UNIVERSAL_VIEW_ID, register_universal_default,
237    };
238
239    use super::{MAX_PANES, Session};
240    use crate::fixture::FixtureTransport;
241
242    use sim_value::build::keyword as sym;
243
244    use sim_kernel::testing::eager_cx as cx;
245
246    fn registry() -> LensRegistry {
247        let mut registry = LensRegistry::new();
248        register_universal_default(&mut registry, false);
249        registry
250    }
251
252    fn open(
253        session: &mut Session<FixtureTransport>,
254        cx: &mut Cx,
255        registry: &LensRegistry,
256        pane: &str,
257    ) -> sim_kernel::Result<Expr> {
258        session.open(
259            cx,
260            registry,
261            sym(pane),
262            sym("doc"),
263            Symbol::new(UNIVERSAL_VIEW_ID),
264            Symbol::new(UNIVERSAL_EDITOR_ID),
265        )
266    }
267
268    #[test]
269    fn open_bounds_the_number_of_panes() {
270        let mut cx = cx();
271        let registry = registry();
272        let mut session = Session::new(FixtureTransport::new().with(sym("doc"), Expr::Nil));
273
274        for index in 0..MAX_PANES {
275            open(&mut session, &mut cx, &registry, &format!("pane-{index}")).unwrap();
276        }
277        // A new distinct pane beyond the cap is refused.
278        assert!(
279            open(&mut session, &mut cx, &registry, "pane-overflow").is_err(),
280            "opening past the pane cap must be refused"
281        );
282        // Re-opening an EXISTING pane still works (it replaces, never grows).
283        open(&mut session, &mut cx, &registry, "pane-0").unwrap();
284    }
285
286    #[test]
287    fn open_rejects_untrusted_pane_names() {
288        let mut cx = cx();
289        let registry = registry();
290        let mut session = Session::new(FixtureTransport::new().with(sym("doc"), Expr::Nil));
291
292        assert!(
293            open(&mut session, &mut cx, &registry, "").is_err(),
294            "empty pane"
295        );
296        let huge = "p".repeat(super::MAX_PANE_NAME + 1);
297        assert!(
298            open(&mut session, &mut cx, &registry, &huge).is_err(),
299            "over-long pane name"
300        );
301        assert!(
302            open(&mut session, &mut cx, &registry, "has space").is_err(),
303            "pane name with a space"
304        );
305    }
306}