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(®istry);
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(), ¤t);
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(¶ms, &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(), ¤t);
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}