Skip to main content

sim_web_shell/
live.rs

1//! The live browser session bridge (VIEW4.05).
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::sync::Arc;
37
38use sim_codec_json::{JsonProjectionMode, project_expr_to_json, project_json_to_expr};
39use sim_kernel::{Cx, DefaultFactory, EagerPolicy, Expr, Result as SimResult, Symbol};
40use sim_lib_view::{
41    LensRegistry, UNIVERSAL_EDITOR_ID, UNIVERSAL_VIEW_ID, register_universal_default,
42};
43use sim_lib_web_bridge::{FixtureTransport, SceneUpdate, Session};
44
45/// The namespace every Intent `kind` symbol lives in (mirrors `sim-lib-intent`).
46const INTENT_NAMESPACE: &str = "intent";
47
48/// The default pane the shell opens the demo resource into. The shipped
49/// `app.js` posts Intents for this pane.
50pub const DEFAULT_PANE: &str = "pane-main";
51
52/// The default resource seeded into the live session for the demo shell.
53pub const DEFAULT_RESOURCE: &str = "demo";
54
55/// A server-held live session: a [`Session`] over a deterministic in-memory
56/// [`FixtureTransport`], its [`LensRegistry`] (with the universal default lens
57/// registered), and the runtime [`Cx`] used to render Scenes.
58///
59/// The blocking HTTP server is single-threaded, so the shell owns one of these
60/// directly and serves every request against it in turn; no lock is needed. A
61/// multi-threaded server would hold this behind a `Mutex`.
62pub struct LiveSession {
63    session: Session<FixtureTransport>,
64    registry: LensRegistry,
65    cx: Cx,
66}
67
68impl LiveSession {
69    /// Build a live session, seed the demo resource, and open it into the
70    /// default pane so Intents can be submitted immediately.
71    pub fn new() -> SimResult<Self> {
72        let mut transport = FixtureTransport::new();
73        transport.set(Symbol::new(DEFAULT_RESOURCE), demo_value());
74        let mut registry = LensRegistry::new();
75        register_universal_default(&mut registry, false);
76        let mut cx = Cx::new(Arc::new(EagerPolicy), Arc::new(DefaultFactory));
77        let mut session = Session::new(transport);
78        session.open(
79            &mut cx,
80            &registry,
81            Symbol::new(DEFAULT_PANE),
82            Symbol::new(DEFAULT_RESOURCE),
83            Symbol::new(UNIVERSAL_VIEW_ID),
84            Symbol::new(UNIVERSAL_EDITOR_ID),
85        )?;
86        Ok(Self {
87            session,
88            registry,
89            cx,
90        })
91    }
92
93    /// Open `resource` into `pane` through the universal default lenses and
94    /// return its initial Scene.
95    pub fn open(&mut self, resource: &str, pane: &str) -> SimResult<Expr> {
96        self.session.open(
97            &mut self.cx,
98            &self.registry,
99            Symbol::new(pane),
100            Symbol::new(resource),
101            Symbol::new(UNIVERSAL_VIEW_ID),
102            Symbol::new(UNIVERSAL_EDITOR_ID),
103        )
104    }
105
106    /// Submit a decoded Intent against `pane`, then pump and return the Scene
107    /// update(s) (each carrying the diff that reconstructs its new Scene).
108    pub fn submit(&mut self, pane: &str, intent: &Expr) -> SimResult<Vec<SceneUpdate>> {
109        self.session
110            .submit_intent(&mut self.cx, &self.registry, &Symbol::new(pane), intent)?;
111        self.session.pump(&mut self.cx, &self.registry)
112    }
113}
114
115/// The demo resource value rendered by the live shell on boot.
116fn demo_value() -> Expr {
117    Expr::Map(vec![
118        (
119            Expr::Symbol(Symbol::new("title")),
120            Expr::String("SIM live session".to_owned()),
121        ),
122        (
123            Expr::Symbol(Symbol::new("note")),
124            Expr::String("edit me".to_owned()),
125        ),
126    ])
127}
128
129/// Decode an Intent from an untagged-JSON request body and lift its envelope
130/// back to faithful `Expr`s. Returns a structured error string on malformed
131/// JSON or a non-object body; it never panics.
132pub fn decode_intent_body(body: &str) -> Result<Expr, String> {
133    let value: serde_json::Value =
134        serde_json::from_str(body).map_err(|err| format!("invalid JSON intent body: {err}"))?;
135    let expr = project_json_to_expr(&value, JsonProjectionMode::UntaggedInterop);
136    lift_intent(expr)
137}
138
139/// Encode a batch of Scene updates as the untagged-JSON `{ "patches": [...] }`
140/// response the browser's patch listener consumes.
141pub fn encode_patches(updates: &[SceneUpdate]) -> String {
142    let patches: Vec<serde_json::Value> = updates
143        .iter()
144        .map(|update| project_expr_to_json(&update.diff, JsonProjectionMode::UntaggedInterop))
145        .collect();
146    serde_json::json!({ "patches": patches }).to_string()
147}
148
149/// Encode a Scene as the untagged-JSON `{ "scene": ... }` response the open
150/// route returns.
151pub fn encode_scene(scene: &Expr) -> String {
152    serde_json::json!({ "scene": project_expr_to_json(scene, JsonProjectionMode::UntaggedInterop) })
153        .to_string()
154}
155
156/// Encode a structured `{ "error": message }` JSON body.
157pub fn error_json(message: &str) -> String {
158    serde_json::json!({ "error": message }).to_string()
159}
160
161/// Lift an untagged-decoded Intent map back to a faithful Intent `Expr`.
162fn lift_intent(expr: Expr) -> Result<Expr, String> {
163    let Expr::Map(entries) = expr else {
164        return Err("intent body must be a JSON object".to_owned());
165    };
166    let mut lifted = Vec::with_capacity(entries.len());
167    for (key, value) in entries {
168        let name = key_name(&key)?;
169        let value = match name.as_str() {
170            "kind" => lift_kind(value)?,
171            "origin" => lift_origin(value),
172            "path" => lift_path(value),
173            _ => value,
174        };
175        lifted.push((Expr::Symbol(Symbol::new(name)), value));
176    }
177    Ok(Expr::Map(lifted))
178}
179
180/// The local name of a map key (a symbol or string key).
181fn key_name(key: &Expr) -> Result<String, String> {
182    match key {
183        Expr::Symbol(symbol) => Ok(symbol.name.to_string()),
184        Expr::String(text) => Ok(text.clone()),
185        other => Err(format!("intent key must be a string, found {other:?}")),
186    }
187}
188
189/// Lift a `kind` field to its `intent/<name>` symbol, stripping a redundant
190/// `intent/` prefix the browser may include.
191fn lift_kind(value: Expr) -> Result<Expr, String> {
192    match value {
193        Expr::Symbol(symbol) => Ok(Expr::Symbol(symbol)),
194        Expr::String(text) => {
195            let local = text.strip_prefix("intent/").unwrap_or(&text);
196            Ok(Expr::Symbol(Symbol::qualified(INTENT_NAMESPACE, local)))
197        }
198        other => Err(format!("intent 'kind' must be a string, found {other:?}")),
199    }
200}
201
202/// Lift the `origin.operator` field to a symbol, leaving the tick untouched.
203fn lift_origin(value: Expr) -> Expr {
204    let Expr::Map(entries) = value else {
205        return value;
206    };
207    let lifted = entries
208        .into_iter()
209        .map(|(key, value)| {
210            let is_operator = matches!(&key, Expr::Symbol(symbol) if &*symbol.name == "operator")
211                || matches!(&key, Expr::String(text) if text == "operator");
212            let value = match value {
213                Expr::String(text) if is_operator => Expr::Symbol(Symbol::new(text)),
214                other => other,
215            };
216            (key, value)
217        })
218        .collect();
219    Expr::Map(lifted)
220}
221
222/// Lift each `path` segment to the `Vector([sym(tag), key])` wire form the
223/// universal editor's path parser expects. The segment tag (`k`/`i`) becomes a
224/// symbol; the key passes through. An empty path (the only shape the shipped
225/// shell emits) round-trips unchanged.
226fn lift_path(value: Expr) -> Expr {
227    let segments = match value {
228        Expr::List(segments) | Expr::Vector(segments) => segments,
229        other => return other,
230    };
231    Expr::List(segments.into_iter().map(lift_segment).collect())
232}
233
234/// Lift a single path segment `[tag, key]` to `Vector([sym(tag), key])`.
235fn lift_segment(segment: Expr) -> Expr {
236    let items = match segment {
237        Expr::List(items) | Expr::Vector(items) => items,
238        other => return other,
239    };
240    let lifted = items
241        .into_iter()
242        .enumerate()
243        .map(|(index, item)| match item {
244            Expr::String(text) if index == 0 => Expr::Symbol(Symbol::new(text)),
245            other => other,
246        })
247        .collect();
248    Expr::Vector(lifted)
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use sim_lib_intent::{Origin, intent};
255
256    fn key_path(key: &str) -> Expr {
257        Expr::List(vec![Expr::Vector(vec![
258            Expr::Symbol(Symbol::new("k")),
259            Expr::Symbol(Symbol::new(key)),
260        ])])
261    }
262
263    fn edit_intent(key: &str, value: &str) -> Expr {
264        intent(
265            "edit-field",
266            Origin::human(1),
267            vec![
268                ("target", demo_value()),
269                ("path", key_path(key)),
270                ("value", Expr::String(value.to_owned())),
271            ],
272        )
273    }
274
275    #[test]
276    fn submit_edit_returns_a_patch_that_reconstructs_the_scene() {
277        let mut live = LiveSession::new().unwrap();
278        let before = live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
279        sim_lib_scene::validate_scene(&before).expect("initial scene is valid");
280
281        let updates = live
282            .submit(DEFAULT_PANE, &edit_intent("title", "changed"))
283            .unwrap();
284        assert_eq!(updates.len(), 1, "the subscribed pane updates exactly once");
285        let update = &updates[0];
286        assert_ne!(update.scene, before, "the Scene changed");
287        let rebuilt = sim_lib_scene::apply(&before, &update.diff).unwrap();
288        assert_eq!(
289            rebuilt, update.scene,
290            "the diff reconstructs the new Scene from the old one"
291        );
292    }
293
294    #[test]
295    fn open_returns_a_valid_scene() {
296        let mut live = LiveSession::new().unwrap();
297        let scene = live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
298        sim_lib_scene::validate_scene(&scene).expect("open returns a valid Scene");
299    }
300
301    #[test]
302    fn a_browser_json_intent_decodes_and_drives_a_root_edit() {
303        // The browser posts untagged JSON with a string `kind` and a root path;
304        // the bridge must lift it into an Intent the universal editor accepts.
305        let body = r#"{"kind":"intent/edit-field","origin":{"operator":"human","at-tick":2},"target":{},"path":[],"value":"hello"}"#;
306        let intent = decode_intent_body(body).unwrap();
307        let kind = match &intent {
308            Expr::Map(entries) => entries.iter().find_map(|(key, value)| {
309                matches!(key, Expr::Symbol(symbol) if &*symbol.name == "kind").then_some(value)
310            }),
311            _ => None,
312        };
313        assert!(
314            matches!(kind, Some(Expr::Symbol(_))),
315            "the kind tag is lifted to a symbol"
316        );
317
318        let mut live = LiveSession::new().unwrap();
319        live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
320        let updates = live.submit(DEFAULT_PANE, &intent).unwrap();
321        assert_eq!(updates.len(), 1);
322    }
323
324    #[test]
325    fn a_malformed_body_is_an_error_not_a_panic() {
326        assert!(decode_intent_body("this is not json").is_err());
327        assert!(
328            decode_intent_body("[1, 2, 3]").is_err(),
329            "a non-object intent body is rejected"
330        );
331    }
332
333    #[test]
334    fn an_intent_without_a_kind_fails_closed_on_submit() {
335        let intent = decode_intent_body(r#"{"origin":{"operator":"human","at-tick":1}}"#).unwrap();
336        let mut live = LiveSession::new().unwrap();
337        assert!(
338            live.submit(DEFAULT_PANE, &intent).is_err(),
339            "an intent without a kind is rejected, not executed"
340        );
341    }
342
343    #[test]
344    fn patches_scenes_and_errors_encode_as_untagged_json() {
345        let mut live = LiveSession::new().unwrap();
346        live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap();
347        let updates = live
348            .submit(DEFAULT_PANE, &edit_intent("title", "x"))
349            .unwrap();
350
351        let patches = encode_patches(&updates);
352        assert!(patches.contains("\"patches\""), "carries a patches array");
353        assert!(patches.contains("scene/patch"), "patches are scene patches");
354
355        let scene = encode_scene(&live.open(DEFAULT_RESOURCE, DEFAULT_PANE).unwrap());
356        assert!(scene.contains("\"scene\""), "carries a scene field");
357
358        assert!(
359            error_json("boom").contains("boom"),
360            "errors carry a message"
361        );
362    }
363}