Skip to main content

sim_web_shell/
live.rs

1//! The live browser session bridge.
2//!
3//! This module turns the embedded browser shell into a live edit surface over
4//! the blocking HTTP server: the browser posts an Intent, the server submits it
5//! through a server-held [`Session`], pumps the resulting Scene diff, and
6//! responds with the patch(es). The browser applies each patch and repaints. It
7//! is a submit/response bridge -- each Intent is one request -- not a streaming
8//! channel.
9//!
10//! # Wire format
11//!
12//! The browser already speaks plain, untagged JSON: `intent.js` builds untagged
13//! Intent objects and `diff.js`/`scene.js` consume untagged Scene patches and
14//! Scenes. So the bridge uses `sim-codec-json`'s untagged interop projection in
15//! both directions. The cookbook route hand-rolls its JSON and never decodes an
16//! `Expr` from a request body, so there was no existing body codec to reuse;
17//! this is the bridge's own decode/encode surface.
18//!
19//! The untagged projection is intentionally lossy (it cannot tell a symbol from
20//! a string), so [`decode_intent_body`] lifts the well-known Intent envelope
21//! back to faithful `Expr`s: the `kind` tag and `origin.operator` become
22//! symbols, and each `path` segment tag (`k`/`i`) becomes a symbol so the
23//! universal editor's path parser accepts it. Every other field passes through
24//! as decoded. The universal default editor edits at the root path, which is the
25//! only shape the shipped browser shell emits, so this lift is sufficient for
26//! the live surface today.
27//!
28//! # Future work
29//!
30//! This is a request/response bridge. A WebSocket (or SSE) channel would let the
31//! server push patches without a client Intent -- needed for agent peers and
32//! collaborative edits -- but that requires an async server and is out of scope
33//! for the blocking HTTP shell. When that lands, the same [`Session::pump`]
34//! output should be streamed rather than returned per request.
35
36use std::collections::BTreeMap;
37use std::fs::File;
38use std::io::Read;
39use std::sync::Arc;
40use std::time::{Duration, Instant};
41
42use sim_codec_json::{JsonProjectionMode, project_expr_to_json, project_json_to_expr};
43use sim_kernel::{Cx, DefaultFactory, EagerPolicy, Expr, Result as SimResult, Symbol};
44use sim_lib_view::{LensRegistry, UNIVERSAL_SURFACE_CODEC_ID, register_universal_default, surface};
45use sim_lib_web_bridge::{FixtureTransport, SceneUpdate, Session};
46
47/// The namespace every Intent `kind` symbol lives in (mirrors `sim-lib-intent`).
48const INTENT_NAMESPACE: &str = "intent";
49
50/// The default pane the shell opens the demo resource into. The shipped
51/// `app.js` posts Intents for this pane.
52pub const DEFAULT_PANE: &str = "pane-main";
53
54/// The default resource seeded into the live session for the demo shell.
55pub const DEFAULT_RESOURCE: &str = "demo";
56
57/// A server-held live session: a [`Session`] over a deterministic in-memory
58/// [`FixtureTransport`], its [`LensRegistry`] (with the universal default lens
59/// registered), and the runtime [`Cx`] used to render Scenes.
60///
61/// The blocking HTTP server is single-threaded, so the shell owns one of these
62/// directly and serves every request against it in turn; no lock is needed. A
63/// multi-threaded server would hold this behind a `Mutex`.
64pub struct LiveSession {
65    session: Session<FixtureTransport>,
66    registry: LensRegistry,
67    cx: Cx,
68}
69
70impl LiveSession {
71    /// Build a live session, seed the demo resource, and open it into the
72    /// default pane so Intents can be submitted immediately.
73    pub fn new() -> SimResult<Self> {
74        let mut transport = FixtureTransport::new();
75        transport.set(Symbol::new(DEFAULT_RESOURCE), demo_value());
76        let mut registry = LensRegistry::new();
77        register_universal_default(&mut registry, false);
78        // bin-boot-exempt: the LiveSession is the realize/EvalFabric Intent/Scene
79        // bridge -- a distinct eval surface with its own transport, not the binary's
80        // boot runtime (that goes through sim_run_core::Bootloader). It owns its cx.
81        let mut cx = Cx::new(Arc::new(EagerPolicy), Arc::new(DefaultFactory)); // bin-boot-exempt
82        let mut session = Session::new(transport);
83        session.open_codec(
84            &mut cx,
85            &registry,
86            Symbol::new(DEFAULT_PANE),
87            Symbol::new(DEFAULT_RESOURCE),
88            Symbol::new(UNIVERSAL_SURFACE_CODEC_ID),
89            surface::preset("webui").expect("webui is a known surface preset"),
90        )?;
91        Ok(Self {
92            session,
93            registry,
94            cx,
95        })
96    }
97
98    /// Open `resource` into `pane` through the universal default lenses and
99    /// return its initial Scene.
100    pub fn open(&mut self, resource: &str, pane: &str) -> SimResult<Expr> {
101        self.session.open_codec(
102            &mut self.cx,
103            &self.registry,
104            Symbol::new(pane),
105            Symbol::new(resource),
106            Symbol::new(UNIVERSAL_SURFACE_CODEC_ID),
107            surface::preset("webui").expect("webui is a known surface preset"),
108        )
109    }
110
111    /// Submit a decoded Intent against `pane`, then pump and return the Scene
112    /// update(s) (each carrying the diff that reconstructs its new Scene).
113    pub fn submit(&mut self, pane: &str, intent: &Expr) -> SimResult<Vec<SceneUpdate>> {
114        self.session.submit_intent_at_rendered_revision(
115            &mut self.cx,
116            &self.registry,
117            &Symbol::new(pane),
118            intent,
119        )?;
120        self.session.pump(&mut self.cx, &self.registry)
121    }
122}
123
124/// One browser-owned reversible surface.
125///
126/// The shell owns lifecycle and opaque browser ids; a product supplies one of
127/// these objects per browser when it needs a non-fixture transport or codec.
128pub trait LiveSurface {
129    /// Open `resource` in `pane` and return its initial Scene.
130    fn open(&mut self, resource: &str, pane: &str) -> SimResult<Expr>;
131
132    /// Submit one Intent and return the resulting Scene updates.
133    fn submit(&mut self, pane: &str, intent: &Expr) -> SimResult<Vec<SceneUpdate>>;
134}
135
136impl LiveSurface for LiveSession {
137    fn open(&mut self, resource: &str, pane: &str) -> SimResult<Expr> {
138        Self::open(self, resource, pane)
139    }
140
141    fn submit(&mut self, pane: &str, intent: &Expr) -> SimResult<Vec<SceneUpdate>> {
142        Self::submit(self, pane, intent)
143    }
144}
145
146/// Object-safe factory for isolated browser-owned live surfaces.
147pub trait LiveSurfaceFactory {
148    /// Construct one fresh surface, including its transport, authority, and
149    /// session-local presentation state.
150    fn create(&self) -> SimResult<Box<dyn LiveSurface>>;
151}
152
153/// Default shell surface factory.
154#[derive(Debug, Default)]
155pub struct DefaultLiveSurfaceFactory;
156
157impl LiveSurfaceFactory for DefaultLiveSurfaceFactory {
158    fn create(&self) -> SimResult<Box<dyn LiveSurface>> {
159        LiveSession::new().map(|surface| Box::new(surface) as Box<dyn LiveSurface>)
160    }
161}
162
163/// Capacity and expiry policy for live browser sessions.
164#[derive(Debug, Clone)]
165pub struct LiveSessionTableConfig {
166    /// Maximum number of browser-owned live surfaces retained at once.
167    pub capacity: usize,
168    /// Maximum idle duration before an opaque browser session is evicted.
169    pub idle_ttl: Duration,
170}
171
172impl Default for LiveSessionTableConfig {
173    fn default() -> Self {
174        Self {
175            capacity: 64,
176            idle_ttl: Duration::from_secs(30 * 60),
177        }
178    }
179}
180
181struct LiveSessionEntry {
182    live: Box<dyn LiveSurface>,
183    last_used: Instant,
184    ordinal: u64,
185}
186
187/// Bounded table of isolated live browser sessions.
188pub struct LiveSessionTable {
189    factory: Box<dyn LiveSurfaceFactory + Send + Sync>,
190    config: LiveSessionTableConfig,
191    sessions: BTreeMap<String, LiveSessionEntry>,
192    next_ordinal: u64,
193}
194
195impl LiveSessionTable {
196    /// Creates a bounded session table with the default capacity and idle TTL.
197    pub fn new(factory: Box<dyn LiveSurfaceFactory + Send + Sync>) -> Self {
198        Self::with_config(factory, LiveSessionTableConfig::default())
199    }
200
201    /// Creates a session table with explicit capacity and idle-expiry policy.
202    pub fn with_config(
203        factory: Box<dyn LiveSurfaceFactory + Send + Sync>,
204        config: LiveSessionTableConfig,
205    ) -> Self {
206        Self {
207            factory,
208            config,
209            sessions: BTreeMap::new(),
210            next_ordinal: 0,
211        }
212    }
213
214    #[cfg(test)]
215    fn len(&self) -> usize {
216        self.sessions.len()
217    }
218
219    /// Opens a resource in a new or existing opaque browser session.
220    pub fn open(
221        &mut self,
222        session_id: Option<&str>,
223        resource: &str,
224        pane: &str,
225    ) -> Result<(String, Expr), String> {
226        self.open_at(session_id, resource, pane, Instant::now())
227    }
228
229    /// Submits one reversible Intent through an existing browser session.
230    pub fn submit(
231        &mut self,
232        session_id: &str,
233        pane: &str,
234        intent: &Expr,
235    ) -> Result<Vec<SceneUpdate>, String> {
236        self.submit_at(session_id, pane, intent, Instant::now())
237    }
238
239    /// Closes an opaque browser session and releases its live surface.
240    pub fn close(&mut self, session_id: &str) -> Result<(), String> {
241        validate_session_id(session_id)?;
242        if self.sessions.remove(session_id).is_some() {
243            Ok(())
244        } else {
245            Err("unknown session id".to_owned())
246        }
247    }
248
249    /// Opens a resource using an explicit clock instant for deterministic hosts.
250    pub fn open_at(
251        &mut self,
252        session_id: Option<&str>,
253        resource: &str,
254        pane: &str,
255        now: Instant,
256    ) -> Result<(String, Expr), String> {
257        self.evict_idle(now);
258        if let Some(session_id) = session_id {
259            let entry = self.entry_mut(session_id, now)?;
260            let scene = entry
261                .live
262                .open(resource, pane)
263                .map_err(|err| err.to_string())?;
264            return Ok((session_id.to_owned(), scene));
265        }
266        self.evict_for_capacity();
267        if self.config.capacity == 0 || self.sessions.len() >= self.config.capacity {
268            return Err("session capacity exhausted".to_owned());
269        }
270        let session_id = self.fresh_unused_session_id()?;
271        let mut live = self.factory.create().map_err(|err| err.to_string())?;
272        let scene = live.open(resource, pane).map_err(|err| err.to_string())?;
273        let ordinal = self.next_ordinal;
274        self.next_ordinal = self.next_ordinal.saturating_add(1);
275        self.sessions.insert(
276            session_id.clone(),
277            LiveSessionEntry {
278                live,
279                last_used: now,
280                ordinal,
281            },
282        );
283        Ok((session_id, scene))
284    }
285
286    /// Submits an Intent using an explicit clock instant for deterministic hosts.
287    pub fn submit_at(
288        &mut self,
289        session_id: &str,
290        pane: &str,
291        intent: &Expr,
292        now: Instant,
293    ) -> Result<Vec<SceneUpdate>, String> {
294        self.evict_idle(now);
295        let entry = self.entry_mut(session_id, now)?;
296        entry
297            .live
298            .submit(pane, intent)
299            .map_err(|err| err.to_string())
300    }
301
302    fn entry_mut(
303        &mut self,
304        session_id: &str,
305        now: Instant,
306    ) -> Result<&mut LiveSessionEntry, String> {
307        validate_session_id(session_id)?;
308        let entry = self
309            .sessions
310            .get_mut(session_id)
311            .ok_or_else(|| "unknown session id".to_owned())?;
312        entry.last_used = now;
313        Ok(entry)
314    }
315
316    fn evict_idle(&mut self, now: Instant) {
317        let ttl = self.config.idle_ttl;
318        self.sessions
319            .retain(|_, entry| now.duration_since(entry.last_used) <= ttl);
320    }
321
322    fn evict_for_capacity(&mut self) {
323        while self.config.capacity > 0 && self.sessions.len() >= self.config.capacity {
324            let Some(victim) = self
325                .sessions
326                .iter()
327                .min_by_key(|(_, entry)| (entry.last_used, entry.ordinal))
328                .map(|(id, _)| id.clone())
329            else {
330                return;
331            };
332            self.sessions.remove(&victim);
333        }
334    }
335
336    fn fresh_unused_session_id(&self) -> Result<String, String> {
337        for _ in 0..8 {
338            let session_id = fresh_session_id()?;
339            if !self.sessions.contains_key(&session_id) {
340                return Ok(session_id);
341            }
342        }
343        Err("could not allocate unique session id".to_owned())
344    }
345}
346
347fn validate_session_id(session_id: &str) -> Result<(), String> {
348    let valid = session_id.len() == 32 && session_id.bytes().all(|b| b.is_ascii_hexdigit());
349    if valid {
350        Ok(())
351    } else {
352        Err("malformed session id".to_owned())
353    }
354}
355
356fn fresh_session_id() -> Result<String, String> {
357    let mut bytes = [0u8; 16];
358    File::open("/dev/urandom")
359        .and_then(|mut file| file.read_exact(&mut bytes))
360        .map_err(|err| format!("could not allocate session id: {err}"))?;
361    Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
362}
363
364/// The demo resource value rendered by the live shell on boot.
365fn demo_value() -> Expr {
366    Expr::Map(vec![
367        (
368            Expr::Symbol(Symbol::new("title")),
369            Expr::String("SIM live session".to_owned()),
370        ),
371        (
372            Expr::Symbol(Symbol::new("note")),
373            Expr::String("edit me".to_owned()),
374        ),
375    ])
376}
377
378/// Decode an Intent from an untagged-JSON request body and lift its envelope
379/// back to faithful `Expr`s. Returns a structured error string on malformed
380/// JSON or a non-object body; it never panics.
381pub fn decode_intent_body(body: &str) -> Result<Expr, String> {
382    let value: serde_json::Value =
383        serde_json::from_str(body).map_err(|err| format!("invalid JSON intent body: {err}"))?;
384    let expr = project_json_to_expr(&value, JsonProjectionMode::UntaggedInterop);
385    lift_intent(expr)
386}
387
388/// Encode a batch of Scene updates as the untagged-JSON `{ "patches": [...] }`
389/// response the browser's patch listener consumes.
390pub fn encode_patches(updates: &[SceneUpdate]) -> String {
391    let patches: Vec<serde_json::Value> = updates
392        .iter()
393        .map(|update| project_expr_to_json(&update.diff, JsonProjectionMode::UntaggedInterop))
394        .collect();
395    serde_json::json!({ "patches": patches }).to_string()
396}
397
398/// Encode a Scene as the untagged-JSON `{ "scene": ... }` response the open
399/// route returns.
400pub fn encode_scene(scene: &Expr) -> String {
401    serde_json::json!({ "scene": project_expr_to_json(scene, JsonProjectionMode::UntaggedInterop) })
402        .to_string()
403}
404
405/// Encode a structured `{ "error": message }` JSON body.
406pub fn error_json(message: &str) -> String {
407    serde_json::json!({ "error": message }).to_string()
408}
409
410/// Lift an untagged-decoded Intent map back to a faithful Intent `Expr`.
411fn lift_intent(expr: Expr) -> Result<Expr, String> {
412    let Expr::Map(entries) = expr else {
413        return Err("intent body must be a JSON object".to_owned());
414    };
415    let mut lifted = Vec::with_capacity(entries.len());
416    for (key, value) in entries {
417        let name = key_name(&key)?;
418        let value = match name.as_str() {
419            "kind" => lift_kind(value)?,
420            "origin" => lift_origin(value),
421            "path" => lift_path(value),
422            _ => value,
423        };
424        lifted.push((Expr::Symbol(Symbol::new(name)), value));
425    }
426    Ok(Expr::Map(lifted))
427}
428
429/// The local name of a map key (a symbol or string key).
430fn key_name(key: &Expr) -> Result<String, String> {
431    match key {
432        Expr::Symbol(symbol) => Ok(symbol.name.to_string()),
433        Expr::String(text) => Ok(text.clone()),
434        other => Err(format!("intent key must be a string, found {other:?}")),
435    }
436}
437
438/// Lift a `kind` field to its `intent/<name>` symbol, stripping a redundant
439/// `intent/` prefix the browser may include.
440fn lift_kind(value: Expr) -> Result<Expr, String> {
441    match value {
442        Expr::Symbol(symbol) => Ok(Expr::Symbol(symbol)),
443        Expr::String(text) => {
444            let local = text.strip_prefix("intent/").unwrap_or(&text);
445            Ok(Expr::Symbol(Symbol::qualified(INTENT_NAMESPACE, local)))
446        }
447        other => Err(format!("intent 'kind' must be a string, found {other:?}")),
448    }
449}
450
451/// Lift the `origin.operator` field to a symbol, leaving the tick untouched.
452fn lift_origin(value: Expr) -> Expr {
453    let Expr::Map(entries) = value else {
454        return value;
455    };
456    let lifted = entries
457        .into_iter()
458        .map(|(key, value)| {
459            let is_operator = matches!(&key, Expr::Symbol(symbol) if &*symbol.name == "operator")
460                || matches!(&key, Expr::String(text) if text == "operator");
461            let value = match value {
462                Expr::String(text) if is_operator => Expr::Symbol(Symbol::new(text)),
463                other => other,
464            };
465            (key, value)
466        })
467        .collect();
468    Expr::Map(lifted)
469}
470
471/// Lift each `path` segment to the `Vector([sym(tag), key])` wire form the
472/// universal editor's path parser expects. The segment tag (`k`/`i`) becomes a
473/// symbol; the key passes through. An empty path (the only shape the shipped
474/// shell emits) round-trips unchanged.
475fn lift_path(value: Expr) -> Expr {
476    let segments = match value {
477        Expr::List(segments) | Expr::Vector(segments) => segments,
478        other => return other,
479    };
480    Expr::List(segments.into_iter().map(lift_segment).collect())
481}
482
483/// Lift a single path segment `[tag, key]` to `Vector([sym(tag), key])`.
484fn lift_segment(segment: Expr) -> Expr {
485    let items = match segment {
486        Expr::List(items) | Expr::Vector(items) => items,
487        other => return other,
488    };
489    let lifted = items
490        .into_iter()
491        .enumerate()
492        .map(|(index, item)| match item {
493            Expr::String(text) if index == 0 => Expr::Symbol(Symbol::new(text)),
494            other => other,
495        })
496        .collect();
497    Expr::Vector(lifted)
498}
499
500#[cfg(test)]
501#[path = "live_tests.rs"]
502mod tests;