Skip to main content

hypen_server/remote/
session.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::{Arc, Mutex};
4
5use hypen_engine::{Engine, Patch};
6use serde_json::Value;
7
8use crate::context::GlobalContext;
9use crate::discovery::ComponentRegistry;
10use crate::module::{ActionHandler, ModuleDefinition};
11use crate::router::HypenRouter;
12use crate::state::State;
13
14use super::types::RemoteMessage;
15
16/// Configuration for creating a [`RemoteSession`].
17pub struct SessionConfig {
18    /// Module name (e.g., "App").
19    pub module_name: String,
20    /// Hypen DSL source for the root UI.
21    pub ui_source: String,
22    /// Component registry with discovered components.
23    pub components: ComponentRegistry,
24    /// Initial state as JSON.
25    pub initial_state: Value,
26    /// Action names registered on this module.
27    pub action_names: Vec<String>,
28    /// SVG resources (name → raw SVG string) for resolving
29    /// `Icon(@resources.xxx)` references. Without these, the engine leaves
30    /// the raw `@resources.xxx` reference in the Create patch props and
31    /// renderers display a fallback glyph (e.g. "...") instead of the icon.
32    pub resources: indexmap::IndexMap<String, String>,
33    /// Additional named modules to register on the engine.
34    /// Each entry is `(module_name, initial_state_json, action_names)`.
35    /// These are registered via `Engine::register_module` so nested
36    /// `module Foo { ... }` blocks in DSL can bind to real state.
37    pub modules: Vec<(String, Value, Vec<String>)>,
38}
39
40impl Default for SessionConfig {
41    fn default() -> Self {
42        Self {
43            module_name: String::new(),
44            ui_source: String::new(),
45            components: ComponentRegistry::new(),
46            initial_state: Value::Null,
47            action_names: Vec::new(),
48            resources: indexmap::IndexMap::new(),
49            modules: vec![],
50        }
51    }
52}
53
54/// Type-erased action handler: `(action_name, payload, current_state) -> new_state`.
55///
56/// Wrapped in `Arc` so the same handler can be shared between the
57/// `primary_handler` slot and the engine-side `on_action` placeholder
58/// closures registered at session construction.
59type ActionHandlerFn = Arc<dyn Fn(&str, Option<&Value>, &Value) -> Value + Send + Sync>;
60
61/// Type-erased module configuration for nested modules in a [`RemoteSession`].
62///
63/// Wraps a [`ModuleDefinition`] of any state type into a form that can be
64/// passed alongside the primary module when building a session.
65///
66/// # Example
67///
68/// ```rust,ignore
69/// let search = Arc::new(HypenApp::module::<SearchState>("Search")
70///     .state(SearchState { query: String::new(), results: vec![] })
71///     .on_action::<SearchPayload>("search", |state, payload, _| {
72///         state.results = do_search(&state.query);
73///     })
74///     .build());
75///
76/// let search_cfg = ModuleSessionConfig::from_definition(search);
77/// ```
78pub struct ModuleSessionConfig {
79    pub(crate) name: String,
80    pub(crate) initial_state: Value,
81    pub(crate) action_handler: ActionHandlerFn,
82    pub(crate) action_names: Vec<String>,
83}
84
85impl ModuleSessionConfig {
86    /// Create a module config from a [`ModuleDefinition`], using the
87    /// definition's initial state.
88    pub fn from_definition<S: State>(def: Arc<ModuleDefinition<S>>) -> Self {
89        let initial_state = serde_json::to_value(&def.initial_state).unwrap_or(Value::Null);
90        Self::build(def, initial_state)
91    }
92
93    /// Create a module config with a per-client state override.
94    pub fn from_definition_with_state<S: State>(def: Arc<ModuleDefinition<S>>, state: S) -> Self {
95        let initial_state = serde_json::to_value(&state).unwrap_or(Value::Null);
96        Self::build(def, initial_state)
97    }
98
99    fn build<S: State>(def: Arc<ModuleDefinition<S>>, initial_state: Value) -> Self {
100        let name = def.name.clone();
101        let action_names = def.action_names();
102        let handler: ActionHandlerFn = Arc::new(move |action, payload, state_json| {
103            // `__hypen_bind` short-circuit: renderer-side two-way binding.
104            // No user handler is registered for this name; we rewrite state
105            // at the dotted path directly. Validates against `S` so a bind
106            // to a non-existent field is silently dropped (matching TS/JS
107            // proxy semantics). See ENGINE_CONTRACT.md §13.
108            if action == "__hypen_bind" {
109                if let Some(payload_val) = payload {
110                    if let Some(obj) = payload_val.as_object() {
111                        if let Some(path) = obj.get("path").and_then(|p| p.as_str()) {
112                            let value = obj.get("value").cloned().unwrap_or(Value::Null);
113                            if let Some(new_state) =
114                                crate::state::apply_bind_to_json::<S>(state_json, path, value)
115                            {
116                                return new_state;
117                            }
118                        }
119                    }
120                }
121                return state_json.clone();
122            }
123
124            let mut state: S = match serde_json::from_value(state_json.clone()) {
125                Ok(s) => s,
126                Err(_) => return state_json.clone(),
127            };
128            // Remote sessions only run sync handlers — async handlers
129            // would need an executor we don't own here.
130            if let Some(ActionHandler::Sync(h)) = def.action_handlers.get(action) {
131                h(&mut state, payload, None);
132            }
133            serde_json::to_value(&state).unwrap_or_else(|_| state_json.clone())
134        });
135        Self {
136            name,
137            initial_state,
138            action_handler: handler,
139            action_names,
140        }
141    }
142}
143
144/// Per-client remote session managing an engine, state, and the wire protocol.
145///
146/// Framework-agnostic: feed it JSON strings, get JSON strings back.
147/// Wire it into any WebSocket library (Axum, Actix, Tungstenite, etc.).
148///
149/// # Usage
150///
151/// ```rust,ignore
152/// let config = SessionConfig { /* ... */ };
153/// let session = RemoteSession::new(config);
154/// session.set_action_handler(|action, payload, state| { /* ... */ });
155///
156/// // On client connect:
157/// let msgs = session.handle_hello(None);
158/// for m in msgs { ws.send(m); }
159///
160/// // On each incoming message:
161/// let responses = session.handle_message(&incoming_json);
162/// for r in responses { ws.send(r); }
163/// ```
164/// Type-erased disconnect handler: `(state_json, session_info) -> ()`.
165type DisconnectHandlerFn = Box<dyn Fn(&Value, &super::SessionInfo) + Send + Sync>;
166/// Type-erased reconnect handler: `(state_json_mut, session_info, saved_state) -> ()`.
167type ReconnectHandlerFn = Box<dyn Fn(&mut Value, &super::SessionInfo, &Value) + Send + Sync>;
168/// Type-erased expire handler: `(session_info) -> ()`.
169type ExpireHandlerFn = Box<dyn Fn(&super::SessionInfo) + Send + Sync>;
170
171/// Type-erased route activation handler. Receives the matched params,
172/// the shared state map (mutable), and the session's [`GlobalContext`].
173/// Fires from the `router.*` action handlers after every navigation
174/// (including the initial mount via [`handle_hello`](RemoteSession::handle_hello)).
175/// Use [`RemoteSession::on_route_enter`] to register one.
176type RouteActivationFn = Box<
177    dyn Fn(&HashMap<String, String>, &mut HashMap<String, Value>, &Arc<GlobalContext>)
178        + Send
179        + Sync,
180>;
181
182pub struct RemoteSession {
183    inner: Mutex<SessionInner>,
184    /// Per-session state for primary and nested modules.
185    state: Arc<Mutex<HashMap<String, Value>>>,
186    /// Catch-all primary-module action handler set via [`set_action_handler`].
187    primary_handler: Arc<Mutex<Option<ActionHandlerFn>>>,
188    /// Route-entry hooks registered via [`Self::on_route_enter`]. Fired
189    /// from the `router.*` engine action handlers after every nav so
190    /// per-route modules can load route-param-dependent data (e.g.
191    /// `/comments/:postId` → load comments) without a bespoke
192    /// pre-navigation action.
193    route_hooks: Arc<Mutex<Vec<(String, RouteActivationFn)>>>,
194    /// Type-erased session lifecycle handlers, populated by `build_from_definition`.
195    on_disconnect: Option<DisconnectHandlerFn>,
196    on_reconnect: Option<ReconnectHandlerFn>,
197    on_expire: Option<ExpireHandlerFn>,
198    /// Per-session router. The engine's reserved `router.*` action
199    /// namespace (installed in [`Self::new`]) drives this router, and
200    /// the accessor [`Self::router`] lets callers attach a
201    /// [`crate::managed_router::ManagedRouter`] or subscribe to
202    /// `on_navigate` for custom mount/unmount logic.
203    router: Arc<HypenRouter>,
204    /// Per-session [`GlobalContext`]. Exposed so callers that attach a
205    /// [`ManagedRouter`](crate::managed_router::ManagedRouter) can reuse
206    /// the same context instance the router is already aware of.
207    context: Arc<GlobalContext>,
208    module_name: String,
209    session_id: String,
210}
211
212struct SessionInner {
213    engine: Engine,
214    ui_source: String,
215    revision: u64,
216    state_subscribed: bool,
217    rendered: bool,
218}
219
220impl RemoteSession {
221    /// Create a new remote session.
222    ///
223    /// Sets up the engine with the component resolver and module, but does NOT
224    /// render yet. The initial render happens in [`handle_hello`].
225    pub fn new(config: SessionConfig) -> Self {
226        let session_id = format!(
227            "session_{}",
228            std::time::SystemTime::now()
229                .duration_since(std::time::UNIX_EPOCH)
230                .unwrap_or_default()
231                .as_nanos()
232        );
233
234        let mut engine = Engine::new();
235
236        // Wire up component resolver from the registry
237        let registry = Arc::new(config.components);
238        let reg: Arc<ComponentRegistry> = Arc::clone(&registry);
239        engine.set_component_resolver(move |name, _ctx_path| {
240            reg.get(name).map(|entry| hypen_engine::ir::ResolvedComponent {
241                source: entry.source.clone(),
242                path: entry
243                    .path
244                    .as_ref()
245                    .map(|p: &PathBuf| p.to_string_lossy().to_string())
246                    .unwrap_or_default(),
247                passthrough: false,
248                lazy: false,
249            })
250        });
251
252        // Set the primary module (state + action declarations). Note that
253        // `set_module` does NOT populate the engine's action->module scope
254        // map, so primary-module actions resolve to `None` in
255        // `engine.action_scope_for()`.
256        let module_meta = hypen_engine::Module::new(&config.module_name)
257            .with_actions(config.action_names.clone());
258        let engine_module =
259            hypen_engine::ModuleInstance::new(module_meta, config.initial_state.clone());
260        engine.set_module(engine_module);
261
262        // Register resources (name → raw SVG) so `Icon(@resources.xxx)` can
263        // be resolved into concrete path data during render. This MUST happen
264        // before handle_hello triggers the initial render.
265        for (name, svg) in &config.resources {
266            engine.register_resource(name, svg);
267        }
268
269        // Register additional named modules (for nested `module Foo { … }` blocks).
270        for (name, initial_state, action_names) in &config.modules {
271            let module_meta =
272                hypen_engine::Module::new(name).with_actions(action_names.clone());
273            let module_inst =
274                hypen_engine::ModuleInstance::new(module_meta, initial_state.clone());
275            engine.register_module(name, module_inst);
276        }
277
278        // Build the per-session state map. Key `""` is the primary slot;
279        // lowercase module names match `engine.action_scope_for(...)` returns.
280        let mut state_map: HashMap<String, Value> = HashMap::new();
281        state_map.insert(String::new(), config.initial_state.clone());
282        for (name, initial_state, _) in &config.modules {
283            state_map.insert(name.to_lowercase(), initial_state.clone());
284        }
285        let state = Arc::new(Mutex::new(state_map));
286        let primary_handler: Arc<Mutex<Option<ActionHandlerFn>>> = Arc::new(Mutex::new(None));
287
288        // Register engine-side placeholder closures for each primary action
289        // name. Firing one of these (via `engine.dispatch_action(...)`) reads
290        // the catch-all primary handler set via `set_action_handler` and
291        // mutates the shared state map. State is pushed to the engine *after*
292        // dispatch returns, in `handle_action`.
293        for action_name in &config.action_names {
294            let name = action_name.clone();
295            let state_arc = Arc::clone(&state);
296            let handler_arc = Arc::clone(&primary_handler);
297            engine.on_action(name.clone(), move |action| {
298                let handler_guard = handler_arc.lock().unwrap();
299                let Some(handler) = handler_guard.as_ref() else { return };
300                let mut state_guard = state_arc.lock().unwrap();
301                let current = state_guard.get("").cloned().unwrap_or(Value::Null);
302                let new_state = handler(&name, action.payload.as_ref(), &current);
303                state_guard.insert(String::new(), new_state);
304            });
305        }
306
307        // Per-session router + context. The router is driven both
308        // internally (by the `router.*` engine action handlers
309        // installed below) and externally (callers can subscribe via
310        // `session.router().on_navigate(...)` or attach a
311        // `ManagedRouter`).
312        let router = Arc::new(HypenRouter::new());
313        let context = Arc::new(GlobalContext::new());
314        context.set_router(Arc::clone(&router));
315
316        let route_hooks: Arc<Mutex<Vec<(String, RouteActivationFn)>>> =
317            Arc::new(Mutex::new(Vec::new()));
318
319        // Install the reserved `@router.*` action namespace. This lets
320        // DSL authors write `.onClick(@router.push, to: "/search")`
321        // and have it dispatch straight to `router.push("/search")`
322        // without any host-side wiring — parity with the TS / Go /
323        // Swift / Kotlin SDKs.
324        //
325        // The handlers also mirror the new path into primary-module
326        // state under the `location` key (when the state shape carries
327        // one). Rendering is then driven by whatever Router IR block
328        // is bound to `@{state.location}` in the primary UI. No defer
329        // is needed here — we're already running inside the engine's
330        // `dispatch_action` under `handle_action`'s `with_capture`
331        // window, so the subsequent `engine.update_state(None, ...)`
332        // that the primary-state write triggers lands in the same
333        // patch response.
334        let install_router_handler = |engine: &mut Engine, name: &'static str| {
335            let router = Arc::clone(&router);
336            let state_arc = Arc::clone(&state);
337            let hooks = Arc::clone(&route_hooks);
338            let ctx = Arc::clone(&context);
339            engine.on_action(name, move |action| {
340                let to = action
341                    .payload
342                    .as_ref()
343                    .and_then(|p| p.get("to"))
344                    .and_then(|v| v.as_str())
345                    .map(|s| s.to_string());
346                let new_path = match name {
347                    "router.push" => to.and_then(|t| {
348                        router.push(&t);
349                        Some(router.current_path())
350                    }),
351                    "router.replace" => to.and_then(|t| {
352                        router.replace(&t);
353                        Some(router.current_path())
354                    }),
355                    "router.back" => {
356                        router.back();
357                        Some(router.current_path())
358                    }
359                    _ => None, // router.forward: server-side no-op
360                };
361                let Some(path) = new_path else { return };
362                // Mirror path into primary state.location (when present)
363                // and fire matching route hooks. Both happen under the
364                // state mutex so the engine's `update_state` pass in
365                // `handle_action` sees a consistent view.
366                let mut g = state_arc.lock().unwrap();
367                if let Some(primary) = g.get_mut("") {
368                    if let Some(obj) = primary.as_object_mut() {
369                        if obj.contains_key("location") {
370                            obj.insert("location".to_string(), Value::String(path.clone()));
371                        }
372                    }
373                }
374                let hooks_guard = hooks.lock().unwrap();
375                for (pattern, hook) in hooks_guard.iter() {
376                    if let Some(m) = hypen_engine::match_path(pattern, &path) {
377                        let params: HashMap<String, String> = m.params.into_iter().collect();
378                        hook(&params, &mut g, &ctx);
379                    }
380                }
381            });
382        };
383        install_router_handler(&mut engine, "router.push");
384        install_router_handler(&mut engine, "router.replace");
385        install_router_handler(&mut engine, "router.back");
386        install_router_handler(&mut engine, "router.forward");
387
388        Self {
389            inner: Mutex::new(SessionInner {
390                engine,
391                ui_source: config.ui_source,
392                revision: 0,
393                state_subscribed: false,
394                rendered: false,
395            }),
396            state,
397            primary_handler,
398            on_disconnect: None,
399            on_reconnect: None,
400            on_expire: None,
401            router,
402            context,
403            route_hooks,
404            module_name: config.module_name,
405            session_id,
406        }
407    }
408
409    /// Register a route-entry hook.
410    ///
411    /// Called whenever the session's router lands on `pattern` (via any
412    /// `@router.push` / `@router.replace` / `@router.back` dispatch).
413    /// The hook receives the extracted route params, a mutable handle
414    /// to the shared state map (keyed by lowercase module name; `""`
415    /// = primary), and the session's [`GlobalContext`].
416    ///
417    /// Typical use: load data for a `:id` / `:postId` route and write
418    /// it into the corresponding nested module's state slot. The state
419    /// changes are flushed to the engine at the end of the surrounding
420    /// [`handle_action`](Self::handle_action) call, so any patches land
421    /// in the same WebSocket response.
422    ///
423    /// ```rust,ignore
424    /// session.on_route_enter("/comments/:postId", move |params, state, _ctx| {
425    ///     let post_id = params.get("postId").cloned().unwrap_or_default();
426    ///     let comments = load_comments(&db, &post_id);
427    ///     if let Some(slot) = state.get_mut("comments") {
428    ///         if let Some(obj) = slot.as_object_mut() {
429    ///             obj.insert("postId".into(), Value::String(post_id));
430    ///             obj.insert("comments".into(), serde_json::to_value(&comments).unwrap());
431    ///         }
432    ///     }
433    /// });
434    /// ```
435    pub fn on_route_enter<F>(&self, pattern: impl Into<String>, handler: F)
436    where
437        F: Fn(&HashMap<String, String>, &mut HashMap<String, Value>, &Arc<GlobalContext>)
438            + Send
439            + Sync
440            + 'static,
441    {
442        self.route_hooks
443            .lock()
444            .unwrap()
445            .push((pattern.into(), Box::new(handler)));
446    }
447
448    /// The router driving this session.
449    ///
450    /// The engine's reserved `router.*` action namespace is wired to
451    /// this router automatically in [`Self::new`] — DSL authors get
452    /// `@router.push` / `@router.replace` / `@router.back` for free.
453    /// Callers that want programmatic nav, to subscribe to
454    /// `on_navigate`, or to attach a
455    /// [`ManagedRouter`](crate::managed_router::ManagedRouter) use this
456    /// handle.
457    pub fn router(&self) -> &Arc<HypenRouter> {
458        &self.router
459    }
460
461    /// The global context associated with this session.
462    ///
463    /// Paired with [`Self::router`]; the session sets the router on the
464    /// context at construction so any attached `ManagedRouter` can find
465    /// it via `context.router()`.
466    pub fn context(&self) -> &Arc<GlobalContext> {
467        &self.context
468    }
469
470    /// Create a session from a [`ModuleDefinition`], automatically wiring up
471    /// typed action handlers, UI source, and resources.
472    ///
473    /// This is the recommended way to create a `RemoteSession` when using the
474    /// [`ModuleBuilder`](crate::module::ModuleBuilder) API. It eliminates manual
475    /// `SessionConfig` construction and raw action handler closures.
476    ///
477    /// # Example
478    ///
479    /// ```rust,ignore
480    /// let module = Arc::new(HypenApp::module::<MyState>("App")
481    ///     .state(MyState::default())
482    ///     .ui_file("./components/App/component.hypen")
483    ///     .on_action::<()>("increment", |s, _, _| s.count += 1)
484    ///     .build());
485    ///
486    /// let session = RemoteSession::from_definition(module, components);
487    /// ```
488    pub fn from_definition<S: State>(
489        def: Arc<ModuleDefinition<S>>,
490        components: ComponentRegistry,
491    ) -> Self {
492        Self::build_from_definition(def, components, None, vec![])
493    }
494
495    /// Create a session from a [`ModuleDefinition`] with a per-client state
496    /// override and optional nested modules.
497    ///
498    /// Use this when initial state varies per client (e.g., loaded from a
499    /// database for the connected user).
500    ///
501    /// # Example
502    ///
503    /// ```rust,ignore
504    /// let search_mod = Arc::new(HypenApp::module::<SearchState>("Search")
505    ///     .state(SearchState::default())
506    ///     .on_action::<()>("search", |s, _, _| { /* filter */ })
507    ///     .build());
508    ///
509    /// let session = RemoteSession::from_definition_with_state(
510    ///     app_module.clone(),
511    ///     components,
512    ///     client_state,
513    ///     vec![ModuleSessionConfig::from_definition(search_mod)],
514    /// );
515    /// ```
516    pub fn from_definition_with_state<S: State>(
517        def: Arc<ModuleDefinition<S>>,
518        components: ComponentRegistry,
519        initial_state: S,
520        modules: Vec<ModuleSessionConfig>,
521    ) -> Self {
522        Self::build_from_definition(def, components, Some(initial_state), modules)
523    }
524
525    /// Internal constructor shared by `from_definition` variants.
526    fn build_from_definition<S: State>(
527        def: Arc<ModuleDefinition<S>>,
528        components: ComponentRegistry,
529        state_override: Option<S>,
530        modules: Vec<ModuleSessionConfig>,
531    ) -> Self {
532        let state_ref = state_override.as_ref().unwrap_or(&def.initial_state);
533        let initial_state_json = serde_json::to_value(state_ref).unwrap_or(Value::Null);
534
535        let ui_source = def
536            .ui_source
537            .clone()
538            .or_else(|| {
539                def.ui_file
540                    .as_ref()
541                    .and_then(|p| std::fs::read_to_string(p).ok())
542            })
543            .unwrap_or_default();
544
545        // Extract (name, state, actions) tuples for SessionConfig + collect handlers
546        let raw_modules: Vec<(String, Value, Vec<String>)> = modules
547            .iter()
548            .map(|m| (m.name.clone(), m.initial_state.clone(), m.action_names.clone()))
549            .collect();
550
551        let config = SessionConfig {
552            module_name: def.name.clone(),
553            ui_source,
554            components,
555            initial_state: initial_state_json,
556            action_names: def.action_names(),
557            resources: def.resource_map.clone(),
558            modules: raw_modules,
559        };
560
561        let mut session = Self::new(config);
562
563        // Clone the definition Arc BEFORE the move-capture below so the
564        // lifecycle handler closures can still reference it.
565        let def_for_disconnect = Arc::clone(&def);
566        let def_for_reconnect = Arc::clone(&def);
567        let def_for_expire = Arc::clone(&def);
568
569        // Bridge: route primary-module action dispatches to the definition's
570        // typed handlers via the catch-all `set_action_handler` slot. The
571        // engine-side placeholder closures registered in `Self::new` for each
572        // primary action name read this slot when fired.
573        session.set_action_handler(move |action, payload, state_json| {
574            // `__hypen_bind` short-circuit — see the note in `Self::build`.
575            if action == "__hypen_bind" {
576                if let Some(payload_val) = payload {
577                    if let Some(obj) = payload_val.as_object() {
578                        if let Some(path) = obj.get("path").and_then(|p| p.as_str()) {
579                            let value = obj.get("value").cloned().unwrap_or(Value::Null);
580                            if let Some(new_state) =
581                                crate::state::apply_bind_to_json::<S>(state_json, path, value)
582                            {
583                                return new_state;
584                            }
585                        }
586                    }
587                }
588                return state_json.clone();
589            }
590
591            let mut state: S = match serde_json::from_value(state_json.clone()) {
592                Ok(s) => s,
593                Err(_) => return state_json.clone(),
594            };
595            if let Some(ActionHandler::Sync(handler)) = def.action_handlers.get(action) {
596                handler(&mut state, payload, None);
597            }
598            serde_json::to_value(&state).unwrap_or_else(|_| state_json.clone())
599        });
600
601        // Register nested module action handlers on the engine itself, one
602        // closure per (module, action) pair. Each closure shares the
603        // module's type-erased `action_handler` (via the Arc inside
604        // `ModuleSessionConfig`) and mutates the per-session state map under
605        // the lowercase module-name key — matching what
606        // `engine.action_scope_for(...)` returns.
607        {
608            let mut inner = session.inner.lock().unwrap();
609            for module_cfg in modules {
610                let scope_key = module_cfg.name.to_lowercase();
611                let handler = Arc::clone(&module_cfg.action_handler);
612                for action_name in &module_cfg.action_names {
613                    let action = action_name.clone();
614                    let scope = scope_key.clone();
615                    let h = Arc::clone(&handler);
616                    let state_arc = Arc::clone(&session.state);
617                    inner.engine.on_action(action.clone(), move |evt| {
618                        let mut state_guard = state_arc.lock().unwrap();
619                        let current = state_guard.get(&scope).cloned().unwrap_or(Value::Null);
620                        let new_state = h(&action, evt.payload.as_ref(), &current);
621                        state_guard.insert(scope.clone(), new_state);
622                    });
623                }
624            }
625        }
626
627        // Build type-erased session lifecycle wrappers from the typed
628        // definition handlers. Each wrapper deserializes the JSON state
629        // into S, calls the typed handler, and serializes back.
630        if def_for_disconnect.on_disconnect.is_some() {
631            session.on_disconnect = Some(Box::new(move |state_json, session_info| {
632                if let Some(ref handler) = def_for_disconnect.on_disconnect {
633                    if let Ok(state) = serde_json::from_value::<S>(state_json.clone()) {
634                        handler(&state, session_info);
635                    }
636                }
637            }));
638        }
639        if def_for_reconnect.on_reconnect.is_some() {
640            session.on_reconnect = Some(Box::new(move |state_json, session_info, saved_state| {
641                if let Some(ref handler) = def_for_reconnect.on_reconnect {
642                    if let Ok(mut state) = serde_json::from_value::<S>(state_json.clone()) {
643                        handler(&mut state, session_info, saved_state);
644                        if let Ok(new_json) = serde_json::to_value(&state) {
645                            *state_json = new_json;
646                        }
647                    }
648                }
649            }));
650        }
651        if def_for_expire.on_expire.is_some() {
652            session.on_expire = Some(Box::new(move |session_info| {
653                if let Some(ref handler) = def_for_expire.on_expire {
654                    handler(session_info);
655                }
656            }));
657        }
658
659        session
660    }
661
662    /// Set the action handler for the session's primary module.
663    ///
664    /// Called whenever a `dispatchAction` message arrives whose action
665    /// belongs to the primary module (i.e. `engine.action_scope_for` returns
666    /// `None`). The handler receives the action name, optional payload, and
667    /// current primary-module state, and must return the new state.
668    ///
669    /// Nested-module handlers are installed internally by
670    /// [`from_definition_with_state`](Self::from_definition_with_state).
671    pub fn set_action_handler<F>(&self, handler: F)
672    where
673        F: Fn(&str, Option<&Value>, &Value) -> Value + Send + Sync + 'static,
674    {
675        *self.primary_handler.lock().unwrap() = Some(Arc::new(handler));
676    }
677
678    /// The session ID assigned to this client.
679    pub fn session_id(&self) -> &str {
680        &self.session_id
681    }
682
683    /// Handle the hello handshake. Returns `[sessionAck, initialTree]` as JSON.
684    ///
685    /// If `client_session_id` is `Some(id)` and a `SessionManager` was used
686    /// to suspend that session earlier, the caller should call
687    /// [`handle_reconnect`](Self::handle_reconnect) with the
688    /// `PendingSession.saved_state` BEFORE calling this method so the state
689    /// is restored before the initial render.
690    ///
691    /// Call this either:
692    /// - When you receive a `hello` message from the client, or
693    /// - Immediately after connection (for clients that don't send hello)
694    pub fn handle_hello(&self, client_session_id: Option<&str>) -> Vec<String> {
695        let mut inner = self.inner.lock().unwrap();
696        let mut messages = Vec::with_capacity(2);
697
698        let is_restored = client_session_id.is_some();
699
700        // 1. sessionAck
701        let ack = RemoteMessage::SessionAck {
702            session_id: client_session_id
703                .unwrap_or(&self.session_id)
704                .to_string(),
705            is_new: !is_restored,
706            is_restored,
707        };
708        if let Ok(json) = ack.to_json() {
709            messages.push(json);
710        }
711
712        // 2. Render the UI (first time only) and capture patches
713        let patches = if !inner.rendered {
714            inner.rendered = true;
715            let ui = inner.ui_source.clone();
716            render_and_capture(&mut inner.engine, &ui)
717        } else {
718            vec![]
719        };
720
721        // 3. initialTree
722        let primary_state = self
723            .state
724            .lock()
725            .unwrap()
726            .get("")
727            .cloned()
728            .unwrap_or(Value::Null);
729        let initial = RemoteMessage::InitialTree {
730            module: self.module_name.clone(),
731            state: primary_state,
732            patches,
733            revision: 0,
734        };
735        if let Ok(json) = initial.to_json() {
736            messages.push(json);
737        }
738
739        messages
740    }
741
742    /// Handle an incoming JSON message. Returns response messages as JSON strings.
743    pub fn handle_message(&self, json: &str) -> Vec<String> {
744        let msg = match RemoteMessage::from_json(json) {
745            Ok(m) => m,
746            Err(_) => return vec![],
747        };
748
749        match msg {
750            RemoteMessage::Hello { session_id, .. } => {
751                self.handle_hello(session_id.as_deref())
752            }
753
754            RemoteMessage::DispatchAction {
755                module,
756                action,
757                payload,
758            } => self.handle_action(&module, &action, payload.as_ref()),
759
760            RemoteMessage::SubscribeState { .. } => {
761                self.inner.lock().unwrap().state_subscribed = true;
762                vec![]
763            }
764
765            _ => vec![],
766        }
767    }
768
769    /// Dispatch an action and return response messages.
770    ///
771    /// Builds an [`Action`](hypen_engine::Action) and fires it via
772    /// `engine.dispatch_action(...)`. The engine routes it to the handler
773    /// closure registered in [`Self::new`] (for primary actions) or in
774    /// [`Self::build_from_definition`] (for nested-module actions). Each
775    /// handler mutates the per-session state map; this method then reads
776    /// the new state out and pushes it back to the engine via
777    /// `engine.update_state(...)`, capturing any patches.
778    ///
779    /// The `module` field on the incoming message is advisory: the engine's
780    /// action scope map is authoritative.
781    fn handle_action(
782        &self,
783        _module: &str,
784        action: &str,
785        payload: Option<&Value>,
786    ) -> Vec<String> {
787        let mut inner = self.inner.lock().unwrap();
788        let mut messages = Vec::new();
789
790        // Build the action and dispatch through the engine. This fires the
791        // closure registered via `engine.on_action(...)` at session creation,
792        // which mutates the per-session state map under the action's scope.
793        let mut action_obj = hypen_engine::dispatch::Action::new(action);
794        if let Some(p) = payload {
795            action_obj = action_obj.with_payload(p.clone());
796        }
797
798        let state_arc = Arc::clone(&self.state);
799        // Snapshot state before dispatch so we can detect which scopes
800        // the handler (and any `router.*` route-enter hooks it triggers)
801        // mutated. Without this flush, route-enter mutations to sibling
802        // scopes would stay in the state map but never reach the
803        // engine, so no patches would ship.
804        let pre: HashMap<String, Value> = state_arc.lock().unwrap().clone();
805        let patches = with_capture(&mut inner.engine, |engine| {
806            // Run the engine-side handler. Errors here mean no handler is
807            // registered (e.g. unknown action) — we still proceed to push
808            // any changed scopes below so the engine sees a consistent
809            // revision.
810            let _ = engine.dispatch_action(action_obj);
811            // Diff the state map against the pre-dispatch snapshot and
812            // push every scope whose value changed. `""` is the primary
813            // slot (passed as `None`); every other key is a nested
814            // module's lowercased name.
815            let post = state_arc.lock().unwrap().clone();
816            for (key, new_state) in &post {
817                if pre.get(key) != Some(new_state) {
818                    let scope_opt = if key.is_empty() { None } else { Some(key.as_str()) };
819                    engine.update_state(scope_opt, new_state.clone());
820                }
821            }
822        });
823
824        inner.revision += 1;
825
826        if !patches.is_empty() {
827            let patch_msg = RemoteMessage::Patch {
828                module: self.module_name.clone(),
829                patches,
830                revision: inner.revision,
831            };
832            if let Ok(json) = patch_msg.to_json() {
833                messages.push(json);
834            }
835        }
836
837        if inner.state_subscribed {
838            // Read the primary state for the StateUpdate message — even when
839            // the action targeted a nested module, the wire protocol's
840            // StateUpdate is keyed to the primary module name.
841            let primary_state = self
842                .state
843                .lock()
844                .unwrap()
845                .get("")
846                .cloned()
847                .unwrap_or(Value::Null);
848            let state_msg = RemoteMessage::StateUpdate {
849                module: self.module_name.clone(),
850                state: primary_state,
851                revision: inner.revision,
852            };
853            if let Ok(json) = state_msg.to_json() {
854                messages.push(json);
855            }
856        }
857
858        messages
859    }
860
861    /// Get a snapshot of the current primary-module state.
862    pub fn get_state(&self) -> Value {
863        self.state
864            .lock()
865            .unwrap()
866            .get("")
867            .cloned()
868            .unwrap_or(Value::Null)
869    }
870
871    /// Get the current revision number.
872    pub fn revision(&self) -> u64 {
873        self.inner.lock().unwrap().revision
874    }
875
876    // -----------------------------------------------------------------
877    // Session lifecycle hooks
878    // -----------------------------------------------------------------
879
880    /// Fire the `on_disconnect` handler with a snapshot of the current
881    /// primary-module state. Call this when the last WebSocket connection
882    /// for the session drops and you're about to suspend it via
883    /// [`SessionManager::suspend_session`].
884    ///
885    /// No-op if no `on_disconnect` handler was registered on the
886    /// [`ModuleDefinition`] (i.e. the session was created via
887    /// `RemoteSession::new` without `from_definition`).
888    pub fn fire_disconnect(&self, session_info: &super::SessionInfo) {
889        if let Some(ref handler) = self.on_disconnect {
890            let state = self.get_state();
891            handler(&state, session_info);
892        }
893    }
894
895    /// Fire the `on_reconnect` handler and apply the saved state to the
896    /// session. Call this when a client resumes a suspended session
897    /// (i.e. after [`SessionManager::resume_session`] returns
898    /// `Some(pending)`).
899    ///
900    /// The handler receives a mutable reference to the current primary
901    /// state (as JSON) and the saved state — it can choose to merge,
902    /// replace, or ignore the saved state. If no handler is registered,
903    /// the saved state replaces the primary state directly.
904    pub fn fire_reconnect(&self, session_info: &super::SessionInfo, saved_state: &Value) {
905        let mut state_guard = self.state.lock().unwrap();
906        let current = state_guard.get_mut("").unwrap();
907        if let Some(ref handler) = self.on_reconnect {
908            handler(current, session_info, saved_state);
909        } else {
910            // Default: apply saved state directly.
911            *current = saved_state.clone();
912        }
913    }
914
915    /// Fire the `on_expire` handler. Call this from the
916    /// [`SessionManager`] suspension's `on_expire` callback when the TTL
917    /// elapses without a reconnect.
918    pub fn fire_expire(&self, session_info: &super::SessionInfo) {
919        if let Some(ref handler) = self.on_expire {
920            handler(session_info);
921        }
922    }
923}
924
925/// Run a closure with a temporary render callback installed on `engine`,
926/// then return whatever patches it produced.
927///
928/// This is the shared "attach callback → mutate engine → detach → drain"
929/// dance used by every state-mutating helper in this module. Pulling it
930/// into one place keeps the per-call sites focused on the actual mutation.
931fn with_capture<F>(engine: &mut Engine, mutate: F) -> Vec<Patch>
932where
933    F: FnOnce(&mut Engine),
934{
935    let patches = Arc::new(Mutex::new(Vec::<Patch>::new()));
936    let capture = Arc::clone(&patches);
937    engine.set_render_callback(move |p| {
938        capture.lock().unwrap().extend_from_slice(p);
939    });
940
941    mutate(engine);
942
943    engine.set_render_callback(|_| {});
944
945    let mut guard = patches.lock().unwrap();
946    std::mem::take(&mut *guard)
947}
948
949/// Parse + render DSL source, capturing patches via a temporary render callback.
950fn render_and_capture(engine: &mut Engine, ui_source: &str) -> Vec<Patch> {
951    with_capture(engine, |engine| {
952        if let Ok(doc) = hypen_parser::parse_document(ui_source) {
953            if let Some(component) = doc.components.first() {
954                let ir_node = hypen_engine::ast_to_ir_node(component);
955                engine.render_ir_node(&ir_node);
956            }
957        }
958    })
959}
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964
965    fn test_config() -> SessionConfig {
966        let mut components = ComponentRegistry::new();
967        components.register(
968            "Greeting",
969            r#"Text("Hello @{state.name}")"#,
970            None,
971        );
972
973        SessionConfig {
974            module_name: "App".to_string(),
975            ui_source: r#"Column { Text("Count: @{state.count}") }"#.to_string(),
976            components,
977            initial_state: serde_json::json!({
978                "count": 0,
979                "name": "World"
980            }),
981            action_names: vec!["increment".to_string()],
982            resources: indexmap::IndexMap::new(),
983            modules: Vec::new(),
984        }
985    }
986
987    #[test]
988    fn test_session_hello_returns_ack_and_tree() {
989        let session = RemoteSession::new(test_config());
990        let msgs = session.handle_hello(None);
991
992        assert_eq!(msgs.len(), 2);
993        assert!(msgs[0].contains("sessionAck"));
994        assert!(msgs[1].contains("initialTree"));
995        assert!(msgs[1].contains("\"count\":0"));
996    }
997
998    #[test]
999    fn test_session_dispatch_action() {
1000        let session = RemoteSession::new(test_config());
1001        session.set_action_handler(|action, _payload, state| {
1002            let mut s = state.clone();
1003            if action == "increment" {
1004                if let Some(count) = s.get_mut("count").and_then(|v| v.as_i64()) {
1005                    s["count"] = serde_json::json!(count + 1);
1006                }
1007            }
1008            s
1009        });
1010
1011        // Initial render
1012        let _ = session.handle_hello(None);
1013
1014        // Dispatch action
1015        let action_json = r#"{"type":"dispatchAction","module":"App","action":"increment"}"#;
1016        let _responses = session.handle_message(action_json);
1017
1018        // Should get patch message back (if engine produced patches)
1019        assert!(session.get_state()["count"] == 1);
1020        assert_eq!(session.revision(), 1);
1021    }
1022
1023    #[test]
1024    fn test_session_state_subscription() {
1025        let session = RemoteSession::new(test_config());
1026        session.set_action_handler(|_action, _payload, state| {
1027            let mut s = state.clone();
1028            s["count"] = serde_json::json!(42);
1029            s
1030        });
1031
1032        let _ = session.handle_hello(None);
1033
1034        // Subscribe to state
1035        let sub_json = r#"{"type":"subscribeState","module":"App"}"#;
1036        session.handle_message(sub_json);
1037
1038        // Now dispatch — should get stateUpdate in response
1039        let action_json = r#"{"type":"dispatchAction","module":"App","action":"set"}"#;
1040        let responses = session.handle_message(action_json);
1041
1042        // Should contain a stateUpdate message
1043        let has_state_update = responses.iter().any(|r| r.contains("stateUpdate"));
1044        assert!(has_state_update);
1045    }
1046
1047    #[test]
1048    fn test_session_hello_via_message() {
1049        let session = RemoteSession::new(test_config());
1050        let hello_json = r#"{"type":"hello"}"#;
1051        let msgs = session.handle_message(hello_json);
1052
1053        assert_eq!(msgs.len(), 2);
1054        assert!(msgs[0].contains("sessionAck"));
1055        assert!(msgs[1].contains("initialTree"));
1056    }
1057
1058    /// Regression: `SessionConfig.resources` must reach the per-session engine
1059    /// so `Icon(@resources.xxx)` resolves to real `__iconPaths` on the wire.
1060    /// Before the fix, the field did not exist and `RemoteSession::new`
1061    /// instantiated an engine with no resources — every Icon patch carried
1062    /// the raw "@resources.xxx" reference string in props and renderers
1063    /// displayed a fallback glyph (e.g. "...").
1064    #[test]
1065    fn test_session_resources_reach_engine_and_render() {
1066        let components = ComponentRegistry::new();
1067
1068        let mut resources = indexmap::IndexMap::new();
1069        let heart_svg = r#"<svg viewBox="0 0 24 24"><path d="M12 21s-7-4.5-7-11a5 5 0 0 1 9-3 5 5 0 0 1 9 3c0 6.5-7 11-7 11z" stroke="currentColor"/></svg>"#;
1070        resources.insert("heart".to_string(), heart_svg.to_string());
1071
1072        let config = SessionConfig {
1073            module_name: "App".to_string(),
1074            ui_source: r#"Icon(@resources.heart)"#.to_string(),
1075            components,
1076            initial_state: serde_json::json!({}),
1077            action_names: vec![],
1078            resources,
1079            modules: Vec::new(),
1080        };
1081        let session = RemoteSession::new(config);
1082        let msgs = session.handle_hello(None);
1083
1084        // Must have sessionAck + initialTree
1085        assert_eq!(msgs.len(), 2, "expected ack + initialTree");
1086        let initial_tree = &msgs[1];
1087
1088        // The Icon create patch must carry resolved icon data, not the raw
1089        // reference. `__iconPaths` is the engine's marker for a resolved icon.
1090        assert!(
1091            initial_tree.contains("__iconPaths"),
1092            "initialTree does not contain __iconPaths — resources did not reach the engine. \
1093             Payload: {}",
1094            initial_tree
1095        );
1096        assert!(
1097            initial_tree.contains(r#""d":"M12 21"#),
1098            "resolved heart path d did not round-trip into the patch stream: {}",
1099            initial_tree
1100        );
1101    }
1102}