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