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 surface codec, commits the operation through `realize`,
6//! and the transport records a change; pumping re-renders only the affected
7//! panes and 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::{
12    LensRegistry, Mode, SurfaceCaps, UNIVERSAL_SURFACE_CODEC_ID, surface, universal_scene,
13};
14
15use crate::transport::{SessionStatus, Transport};
16
17/// The largest number of distinct panes one session may hold at once. Opening
18/// beyond this is refused: untrusted `pane` query values must not grow the
19/// per-pane work [`Session::pump`] does on every event without bound.
20const MAX_PANES: usize = 64;
21
22/// The largest accepted pane-name length, bounding an untrusted `pane` value.
23const MAX_PANE_NAME: usize = 128;
24
25/// The largest accepted resource-name length, bounding an untrusted `resource`
26/// value.
27const MAX_RESOURCE_NAME: usize = 512;
28
29/// Reject a pane name that is empty, over-long, or not printable ASCII (the
30/// `pane` query param is untrusted).
31fn validate_pane_name(pane: &Symbol) -> Result<()> {
32    let name = pane.as_qualified_str();
33    if name.is_empty() || name.len() > MAX_PANE_NAME {
34        return Err(Error::HostError(format!(
35            "pane name must be 1..={MAX_PANE_NAME} bytes, got {}",
36            name.len()
37        )));
38    }
39    if !name.bytes().all(|byte| byte.is_ascii_graphic()) {
40        return Err(Error::HostError(
41            "pane name must be printable ASCII without spaces".to_owned(),
42        ));
43    }
44    Ok(())
45}
46
47/// Reject a resource name that is empty or over-long (the `resource` query
48/// param is untrusted). Charset stays lenient; an unknown resource fails the
49/// transport read anyway.
50fn validate_resource_name(resource: &Symbol) -> Result<()> {
51    let name = resource.as_qualified_str();
52    if name.is_empty() || name.len() > MAX_RESOURCE_NAME {
53        return Err(Error::HostError(format!(
54            "resource name must be 1..={MAX_RESOURCE_NAME} bytes, got {}",
55            name.len()
56        )));
57    }
58    Ok(())
59}
60
61/// A live binding of a pane to a resource and its lenses.
62struct Subscription {
63    pane: Symbol,
64    resource: Symbol,
65    codec: Symbol,
66    caps: SurfaceCaps,
67    rendered_value: Expr,
68    last_scene: Expr,
69}
70
71/// A re-rendered Scene for a pane, with the diff from its previous Scene.
72#[derive(Clone, Debug)]
73pub struct SceneUpdate {
74    /// The pane that updated.
75    pub pane: Symbol,
76    /// The full new Scene.
77    pub scene: Expr,
78    /// The diff from the previous Scene (a `scene/patch` value).
79    pub diff: Expr,
80}
81
82/// A session over a transport, with per-pane subscriptions and an experience
83/// mode. The mode is session state (a value); switching it never changes the
84/// values being shown.
85pub struct Session<T: Transport> {
86    transport: T,
87    subscriptions: Vec<Subscription>,
88    mode: Mode,
89}
90
91impl<T: Transport> Session<T> {
92    /// Start a session over `transport` in Builder mode.
93    pub fn new(transport: T) -> Self {
94        Self {
95            transport,
96            subscriptions: Vec::new(),
97            mode: Mode::Builder,
98        }
99    }
100
101    /// The visible connection status.
102    pub fn status(&self) -> SessionStatus {
103        self.transport.status()
104    }
105
106    /// The active experience mode.
107    pub fn mode(&self) -> Mode {
108        self.mode
109    }
110
111    /// Handle an `intent/set-mode`, switching the session mode. The values being
112    /// shown are never read or written.
113    pub fn set_mode(&mut self, intent: &Expr) -> Result<()> {
114        match sim_value::access::field(intent, "kind") {
115            Some(Expr::Symbol(kind)) if &*kind.name == "set-mode" => {}
116            _ => {
117                return Err(Error::HostError(
118                    "set_mode expects an intent/set-mode".to_owned(),
119                ));
120            }
121        }
122        let mode = match sim_value::access::field(intent, "mode") {
123            Some(Expr::Symbol(symbol)) => Mode::from_name(&symbol.name),
124            _ => None,
125        };
126        self.mode = mode.ok_or_else(|| {
127            Error::HostError(
128                "intent/set-mode 'mode' must be household, builder, or systems".to_owned(),
129            )
130        })?;
131        Ok(())
132    }
133
134    /// Render a value through the universal default lens at the session's mode
135    /// depth (Household/Builder/Systems show progressively more).
136    pub fn render_universal(&self, value: &Expr) -> Expr {
137        universal_scene(value, self.mode)
138    }
139
140    /// Mutable access to the transport (for example to simulate disconnect in
141    /// tests, or to drive reconnection).
142    pub fn transport_mut(&mut self) -> &mut T {
143        &mut self.transport
144    }
145
146    /// Open `resource` into `pane` with a canonical reversible surface codec;
147    /// render and subscribe. Returns the initial Scene.
148    pub fn open_codec(
149        &mut self,
150        cx: &mut Cx,
151        registry: &LensRegistry,
152        pane: Symbol,
153        resource: Symbol,
154        codec: Symbol,
155        caps: SurfaceCaps,
156    ) -> Result<Expr> {
157        validate_pane_name(&pane)?;
158        validate_resource_name(&resource)?;
159        let replacing = self.subscriptions.iter().any(|sub| sub.pane == pane);
160        if !replacing && self.subscriptions.len() >= MAX_PANES {
161            return Err(Error::HostError(format!(
162                "session is at its pane limit ({MAX_PANES}); close a pane before opening another"
163            )));
164        }
165        let surface_codec = registry
166            .surface_codec(&codec)
167            .ok_or_else(|| Error::UnknownSymbol {
168                symbol: codec.clone(),
169            })?;
170        let value = self.transport.read(cx, &resource)?;
171        let scene = surface_codec.encode(cx, &value, &caps)?;
172        self.subscriptions.retain(|sub| sub.pane != pane);
173        self.subscriptions.push(Subscription {
174            pane,
175            resource,
176            codec,
177            caps,
178            rendered_value: value,
179            last_scene: scene.clone(),
180        });
181        Ok(scene)
182    }
183
184    /// Compatibility adapter for callers that still pass split view/editor lens
185    /// ids. The bridge session itself stores the canonical
186    /// [`SurfaceCodec`](sim_lib_view::SurfaceCodec) id and surface caps.
187    pub fn open(
188        &mut self,
189        cx: &mut Cx,
190        registry: &LensRegistry,
191        pane: Symbol,
192        resource: Symbol,
193        _view_lens: Symbol,
194        _editor_lens: Symbol,
195    ) -> Result<Expr> {
196        self.open_codec(
197            cx,
198            registry,
199            pane,
200            resource,
201            Symbol::new(UNIVERSAL_SURFACE_CODEC_ID),
202            surface::preset("desktop").expect("desktop is a known surface preset"),
203        )
204    }
205
206    /// Submit an Intent against the value shown in `pane`: decode through the
207    /// pane's surface codec and commit the operation through `realize`.
208    pub fn submit_intent(
209        &mut self,
210        cx: &mut Cx,
211        registry: &LensRegistry,
212        pane: &Symbol,
213        intent: &Expr,
214    ) -> Result<()> {
215        self.submit_intent_with_policy(cx, registry, pane, intent, false)
216    }
217
218    /// Submit an Intent only if the pane still reflects the value rendered when
219    /// it was last opened or pumped. This is the optimistic revision path for
220    /// browser clients that include a rendered frame revision in their request.
221    pub fn submit_intent_at_rendered_revision(
222        &mut self,
223        cx: &mut Cx,
224        registry: &LensRegistry,
225        pane: &Symbol,
226        intent: &Expr,
227    ) -> Result<()> {
228        self.submit_intent_with_policy(cx, registry, pane, intent, true)
229    }
230
231    fn submit_intent_with_policy(
232        &mut self,
233        cx: &mut Cx,
234        registry: &LensRegistry,
235        pane: &Symbol,
236        intent: &Expr,
237        require_rendered_revision: bool,
238    ) -> Result<()> {
239        let (resource, codec, rendered_value) = {
240            let sub = self
241                .subscriptions
242                .iter()
243                .find(|sub| &sub.pane == pane)
244                .ok_or_else(|| Error::HostError(format!("pane '{pane}' is not open")))?;
245            (
246                sub.resource.clone(),
247                sub.codec.clone(),
248                sub.rendered_value.clone(),
249            )
250        };
251        let value = if require_rendered_revision {
252            rendered_value.clone()
253        } else {
254            self.transport.read(cx, &resource)?
255        };
256        let surface_codec = registry
257            .surface_codec(&codec)
258            .ok_or(Error::UnknownSymbol { symbol: codec })?;
259        let draft = surface_codec.decode(cx, &value, intent)?;
260        let operation = surface_codec.commit(cx, &draft)?;
261        self.transport.commit_operation(
262            cx,
263            &resource,
264            &operation,
265            require_rendered_revision.then_some(&rendered_value),
266        )?;
267        Ok(())
268    }
269
270    /// Drain pending changes and re-render only the affected panes, returning a
271    /// Scene update (with diff) for each.
272    pub fn pump(&mut self, cx: &mut Cx, registry: &LensRegistry) -> Result<Vec<SceneUpdate>> {
273        let events = self.transport.drain_events(cx)?;
274        let mut updates = Vec::new();
275        let Self {
276            transport,
277            subscriptions,
278            ..
279        } = self;
280        for event in events {
281            for sub in subscriptions
282                .iter_mut()
283                .filter(|sub| sub.resource == event.resource)
284            {
285                let value = transport.read(cx, &sub.resource)?;
286                let surface_codec =
287                    registry
288                        .surface_codec(&sub.codec)
289                        .ok_or_else(|| Error::UnknownSymbol {
290                            symbol: sub.codec.clone(),
291                        })?;
292                let scene = surface_codec.encode(cx, &value, &sub.caps)?;
293                let diff = sim_lib_scene::diff(&sub.last_scene, &scene);
294                sub.rendered_value = value;
295                sub.last_scene = scene.clone();
296                updates.push(SceneUpdate {
297                    pane: sub.pane.clone(),
298                    scene,
299                    diff,
300                });
301            }
302        }
303        Ok(updates)
304    }
305}
306
307#[cfg(test)]
308mod tests {
309
310    use sim_kernel::{Cx, Expr, Symbol};
311    use sim_lib_view::{
312        LensRegistry, UNIVERSAL_EDITOR_ID, UNIVERSAL_VIEW_ID, register_universal_default,
313    };
314
315    use super::{MAX_PANES, Session};
316    use crate::fixture::FixtureTransport;
317
318    use sim_value::build::keyword as sym;
319
320    use sim_kernel::testing::eager_cx as cx;
321
322    fn registry() -> LensRegistry {
323        let mut registry = LensRegistry::new();
324        register_universal_default(&mut registry, false);
325        registry
326    }
327
328    fn open(
329        session: &mut Session<FixtureTransport>,
330        cx: &mut Cx,
331        registry: &LensRegistry,
332        pane: &str,
333    ) -> sim_kernel::Result<Expr> {
334        session.open(
335            cx,
336            registry,
337            sym(pane),
338            sym("doc"),
339            Symbol::new(UNIVERSAL_VIEW_ID),
340            Symbol::new(UNIVERSAL_EDITOR_ID),
341        )
342    }
343
344    #[test]
345    fn open_bounds_the_number_of_panes() {
346        let mut cx = cx();
347        let registry = registry();
348        let mut session = Session::new(FixtureTransport::new().with(sym("doc"), Expr::Nil));
349
350        for index in 0..MAX_PANES {
351            open(&mut session, &mut cx, &registry, &format!("pane-{index}")).unwrap();
352        }
353        // A new distinct pane beyond the cap is refused.
354        assert!(
355            open(&mut session, &mut cx, &registry, "pane-overflow").is_err(),
356            "opening past the pane cap must be refused"
357        );
358        // Re-opening an EXISTING pane still works (it replaces, never grows).
359        open(&mut session, &mut cx, &registry, "pane-0").unwrap();
360    }
361
362    #[test]
363    fn open_rejects_untrusted_pane_names() {
364        let mut cx = cx();
365        let registry = registry();
366        let mut session = Session::new(FixtureTransport::new().with(sym("doc"), Expr::Nil));
367
368        assert!(
369            open(&mut session, &mut cx, &registry, "").is_err(),
370            "empty pane"
371        );
372        let huge = "p".repeat(super::MAX_PANE_NAME + 1);
373        assert!(
374            open(&mut session, &mut cx, &registry, &huge).is_err(),
375            "over-long pane name"
376        );
377        assert!(
378            open(&mut session, &mut cx, &registry, "has space").is_err(),
379            "pane name with a space"
380        );
381    }
382}