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