Skip to main content

hypen_server/
managed_router.rs

1//! Managed router — orchestrates module mount/unmount on route changes.
2//!
3//! A [`ManagedRouter`] subscribes to a [`HypenRouter`] and rotates a single
4//! "active module" through the engine as the URL changes. It mirrors the
5//! TS / Go / Swift / Kotlin implementations:
6//!
7//! * First visit to a route: `instance constructed → on_activated`.
8//! * Navigate away: `on_deactivated` → (persist OR `on_destroyed`).
9//! * Revisit (persisted): `on_activated` (the constructor / `on_created`
10//!   does not re-run because the cached instance is reused).
11//!
12//! ## How Rust differs from the other SDKs
13//!
14//! TS/Go/Swift/Kotlin look up `RouteDefinition.component` against a global
15//! `HypenApp` registry of `name → ModuleDefinition`. The Rust SDK doesn't
16//! carry a type-erased registry of typed `ModuleDefinition<S>`s, so each
17//! [`RouteDefinition`] here owns a **factory** that constructs the module
18//! on demand. The factory returns an [`Arc<dyn ManagedModule>`] — a
19//! type-erased trait object — which the router then activates / persists /
20//! destroys.
21//!
22//! `ModuleInstance<S>` implements [`ManagedModule`] for any `S: State`, so
23//! a typical site looks like:
24//!
25//! ```ignore
26//! let app = Arc::new(HypenApp::default());
27//! let def = Arc::new(HypenApp::module::<HomeState>("Home")
28//!     .state(HomeState::default())
29//!     .ui(...)
30//!     .build());
31//!
32//! let mut router = ManagedRouter::new(
33//!     app.router_arc(),
34//!     Arc::clone(app.context_arc()),
35//!     ManagedRouterOptions::default(),
36//! );
37//! router.add_route(RouteDefinition::factory("/", "Home", {
38//!     let app = Arc::clone(&app);
39//!     let def = Arc::clone(&def);
40//!     move || {
41//!         let inst = app.instantiate(Arc::clone(&def))?;
42//!         Ok(Arc::new(inst) as Arc<dyn ManagedModule>)
43//!     }
44//! }));
45//! router.start();
46//! ```
47
48use std::collections::{HashMap, VecDeque};
49use std::sync::{Arc, Mutex};
50
51use crate::context::GlobalContext;
52use crate::error::Result;
53use crate::events::SubscriptionId;
54use crate::module::ModuleInstance;
55use crate::router::HypenRouter;
56use crate::state::State;
57
58/// Default LRU cap for the persist cache. Matches every other SDK
59/// (`DEFAULT_ROUTER_CACHE_SIZE` in the engine).
60pub const DEFAULT_PERSIST_CAP: usize = 10;
61
62/// Type-erased module handle managed by [`ManagedRouter`].
63///
64/// Implemented for [`ModuleInstance<S>`] for every `S: State`; users with
65/// custom module shells can implement it themselves to participate in
66/// route-driven lifecycle.
67pub trait ManagedModule: Send + Sync {
68    fn name(&self) -> &str;
69    fn mount(&self);
70    fn activate(&self);
71    fn deactivate(&self);
72    fn destroy(&self);
73    /// Whether this module's definition opted into persistence.
74    /// Defaults to `false` — match the TS / Swift contract where
75    /// persistence is opt-in via `.persist()`.
76    fn is_persistent(&self) -> bool {
77        false
78    }
79}
80
81impl<S: State> ManagedModule for ModuleInstance<S> {
82    fn name(&self) -> &str {
83        ModuleInstance::name(self)
84    }
85    fn mount(&self) {
86        ModuleInstance::mount(self)
87    }
88    fn activate(&self) {
89        ModuleInstance::activate(self)
90    }
91    fn deactivate(&self) {
92        ModuleInstance::deactivate(self)
93    }
94    fn destroy(&self) {
95        ModuleInstance::unmount(self)
96    }
97    fn is_persistent(&self) -> bool {
98        // Reach into the definition via the public `is_mounted` accessor's
99        // sibling — `ModuleInstance` doesn't expose `definition` publicly,
100        // so we mirror the persist flag through the trait at registration
101        // time by leaning on `ModuleDefinition::is_persistent`. That's
102        // enforced by the `RouteDefinition::persist` override below; this
103        // default just reports `false` and is overridden when the route
104        // explicitly opts in.
105        false
106    }
107}
108
109/// Factory closure that constructs a fresh [`ManagedModule`] on demand.
110///
111/// `ManagedRouter` calls this every time a route mounts and there is no
112/// persisted instance to restore. Returning `Err` propagates through
113/// `handle_route_change` and leaves the router in the "no active module"
114/// state for the failed route — same as the other SDKs when a definition
115/// can't be resolved.
116pub type ModuleFactory =
117    Arc<dyn Fn() -> Result<Arc<dyn ManagedModule>> + Send + Sync>;
118
119/// A single route entry registered with [`ManagedRouter`].
120pub struct RouteDefinition {
121    /// Path pattern (`/`, `/users/:id`, `/api/*`).
122    pub path: String,
123    /// Component name — used as the persist-cache key (lowercased) and
124    /// surfaced in logs / `get_active_route()` for symmetry with the
125    /// other SDKs.
126    pub component: String,
127    /// Factory that builds a fresh module instance.
128    pub factory: ModuleFactory,
129    /// Whether the instance should be cached on `unmount` and reused on
130    /// revisit. `None` = inherit the [`ManagedRouter`]'s default
131    /// (currently `false`); `Some(true|false)` = explicit override.
132    pub persist: Option<bool>,
133}
134
135impl RouteDefinition {
136    /// Convenience constructor with the factory passed inline.
137    pub fn factory<F>(path: impl Into<String>, component: impl Into<String>, f: F) -> Self
138    where
139        F: Fn() -> Result<Arc<dyn ManagedModule>> + Send + Sync + 'static,
140    {
141        Self {
142            path: path.into(),
143            component: component.into(),
144            factory: Arc::new(f),
145            persist: None,
146        }
147    }
148
149    /// Override the router's default persist behavior for this route.
150    pub fn persist(mut self, persist: bool) -> Self {
151        self.persist = Some(persist);
152        self
153    }
154
155    fn cache_key(&self) -> String {
156        self.component.to_lowercase()
157    }
158}
159
160/// Tunables for [`ManagedRouter`]. Mirrors `ManagedRouterOptions` in the
161/// other SDKs.
162#[derive(Debug, Clone)]
163pub struct ManagedRouterOptions {
164    /// LRU cap on the persist cache. Once exceeded, the
165    /// least-recently-used entry is destroyed to make room. Defaults to
166    /// [`DEFAULT_PERSIST_CAP`].
167    pub max_persisted_modules: usize,
168    /// Default `persist` value when [`RouteDefinition::persist`] is
169    /// `None`. Matches the TS contract: opt-in (`false`).
170    pub default_persist: bool,
171}
172
173impl Default for ManagedRouterOptions {
174    fn default() -> Self {
175        Self {
176            max_persisted_modules: DEFAULT_PERSIST_CAP,
177            default_persist: false,
178        }
179    }
180}
181
182struct State_ {
183    routes: Vec<RouteDefinition>,
184    active: Option<(usize, Arc<dyn ManagedModule>)>,
185    persisted: HashMap<String, Arc<dyn ManagedModule>>,
186    /// Most-recently-used last. Kept in lock-step with `persisted`.
187    lru: VecDeque<String>,
188    unsub: Option<SubscriptionId>,
189}
190
191/// Orchestrates module mount/unmount on route changes.
192///
193/// See the module-level docs for the high-level lifecycle. Hold via
194/// `Arc<ManagedRouter>` if multiple owners need to drive it
195/// (`router.on_navigate` will keep an internal clone alive).
196pub struct ManagedRouter {
197    router: Arc<HypenRouter>,
198    global_context: Arc<GlobalContext>,
199    options: ManagedRouterOptions,
200    inner: Arc<Mutex<State_>>,
201}
202
203impl ManagedRouter {
204    pub fn new(
205        router: Arc<HypenRouter>,
206        global_context: Arc<GlobalContext>,
207        options: ManagedRouterOptions,
208    ) -> Self {
209        Self {
210            router,
211            global_context,
212            options,
213            inner: Arc::new(Mutex::new(State_ {
214                routes: Vec::new(),
215                active: None,
216                persisted: HashMap::new(),
217                lru: VecDeque::new(),
218                unsub: None,
219            })),
220        }
221    }
222
223    pub fn add_route(&self, route: RouteDefinition) -> &Self {
224        self.inner.lock().unwrap().routes.push(route);
225        self
226    }
227
228    /// Subscribe to the underlying router and mount the initial route.
229    ///
230    /// Idempotent — calling `start()` a second time without `stop()` is a
231    /// no-op (the existing subscription stays).
232    pub fn start(&self) {
233        // Subscribe first so any nav fired between this and the initial
234        // mount is observed.
235        {
236            let mut g = self.inner.lock().unwrap();
237            if g.unsub.is_some() {
238                return;
239            }
240            let inner = Arc::clone(&self.inner);
241            let router = Arc::clone(&self.router);
242            let context = Arc::clone(&self.global_context);
243            let options = self.options.clone();
244            let sub = self.router.on_navigate(move |_payload| {
245                let path = router.current_path();
246                handle_route_change(&inner, &context, &options, &path);
247            });
248            g.unsub = Some(sub);
249        }
250        let path = self.router.current_path();
251        handle_route_change(&self.inner, &self.global_context, &self.options, &path);
252    }
253
254    /// Unsubscribe and tear down both the active and persisted modules.
255    pub fn stop(&self) {
256        let (active, persisted, sub) = {
257            let mut g = self.inner.lock().unwrap();
258            let active = g.active.take().map(|(_, m)| m);
259            let persisted: Vec<_> = g.persisted.drain().collect();
260            g.lru.clear();
261            (active, persisted, g.unsub.take())
262        };
263        if let Some(sub) = sub {
264            self.router.off(sub);
265        }
266        if let Some(m) = active {
267            m.deactivate();
268            m.destroy();
269            self.global_context.unregister_module(&m.name().to_lowercase());
270        }
271        for (key, m) in persisted {
272            m.destroy();
273            self.global_context.unregister_module(&key);
274        }
275    }
276
277    pub fn get_active_module(&self) -> Option<Arc<dyn ManagedModule>> {
278        self.inner
279            .lock()
280            .unwrap()
281            .active
282            .as_ref()
283            .map(|(_, m)| Arc::clone(m))
284    }
285
286    pub fn get_active_route_path(&self) -> Option<String> {
287        let g = self.inner.lock().unwrap();
288        g.active.as_ref().map(|(idx, _)| g.routes[*idx].path.clone())
289    }
290
291    /// Snapshot of currently-cached module keys (lowercase). Test-only
292    /// helper — exposed because the persist cache is otherwise private.
293    pub fn persisted_keys(&self) -> Vec<String> {
294        self.inner.lock().unwrap().lru.iter().cloned().collect()
295    }
296}
297
298impl Drop for ManagedRouter {
299    fn drop(&mut self) {
300        // Best-effort teardown so subscriptions don't outlive the router.
301        if self.inner.lock().unwrap().unsub.is_some() {
302            self.stop();
303        }
304    }
305}
306
307fn handle_route_change(
308    inner: &Arc<Mutex<State_>>,
309    global_context: &Arc<GlobalContext>,
310    options: &ManagedRouterOptions,
311    path: &str,
312) {
313    // Route resolution — done under lock against a snapshot of the
314    // routes vec to avoid holding the lock across factory invocation.
315    let matched_idx = {
316        let g = inner.lock().unwrap();
317        g.routes
318            .iter()
319            .position(|r| hypen_engine::match_path(&r.path, path).is_some())
320    };
321
322    let Some(idx) = matched_idx else {
323        unmount_active(inner, global_context, options);
324        return;
325    };
326
327    // Same-route nav is a no-op.
328    {
329        let g = inner.lock().unwrap();
330        if let Some((active_idx, _)) = &g.active {
331            if *active_idx == idx {
332                return;
333            }
334        }
335    }
336
337    // Pull the target from cache *before* unmounting the previous route.
338    // Otherwise, when the previous route is itself persisted and the
339    // cache is at capacity, the LRU eviction in `unmount_active` would
340    // evict the very entry we're about to restore — silently turning
341    // the cache hit into a fresh construction.
342    let preloaded = {
343        let mut g = inner.lock().unwrap();
344        let key = g.routes[idx].cache_key();
345        g.persisted.remove(&key).map(|m| {
346            g.lru.retain(|k| k != &key);
347            m
348        })
349    };
350    unmount_active(inner, global_context, options);
351    mount_route(inner, global_context, idx, preloaded);
352}
353
354fn mount_route(
355    inner: &Arc<Mutex<State_>>,
356    global_context: &Arc<GlobalContext>,
357    idx: usize,
358    preloaded: Option<Arc<dyn ManagedModule>>,
359) {
360    // Decide cache-hit vs. fresh construction under lock. The factory
361    // (which may do non-trivial work) always runs outside the lock.
362    enum Plan {
363        Hit(Arc<dyn ManagedModule>),
364        Miss { key: String, factory: ModuleFactory },
365    }
366    let plan = if let Some(m) = preloaded {
367        Plan::Hit(m)
368    } else {
369        let g = inner.lock().unwrap();
370        let key = g.routes[idx].cache_key();
371        let factory = Arc::clone(&g.routes[idx].factory);
372        Plan::Miss { key, factory }
373    };
374
375    let module = match plan {
376        Plan::Hit(m) => {
377            // Cache hit — re-record the GlobalContext registration was
378            // never removed (persist branch in unmount keeps it), so we
379            // only need to flip the active slot.
380            let mut g = inner.lock().unwrap();
381            g.active = Some((idx, Arc::clone(&m)));
382            drop(g);
383            m.activate();
384            return;
385        }
386        Plan::Miss { key, factory } => match (factory)() {
387            Ok(m) => {
388                // Register in GlobalContext (matches every other SDK —
389                // siblings can read this module's state via
390                // `context.get_module_state(name)`).
391                global_context.register_module_state(&key, serde_json::Value::Null);
392                let mut g = inner.lock().unwrap();
393                g.active = Some((idx, Arc::clone(&m)));
394                drop(g);
395                m
396            }
397            Err(e) => {
398                // Mirror Swift / Kotlin: log and clear the active slot.
399                // No `log` crate dep here, so use stderr.
400                eprintln!(
401                    "[hypen-server] managed_router: factory for component '{}' failed: {e}",
402                    key_for_idx(inner, idx)
403                );
404                let mut g = inner.lock().unwrap();
405                g.active = None;
406                return;
407            }
408        },
409    };
410
411    // First-time mount: fire on_created (via `mount`) then on_activated.
412    module.mount();
413    module.activate();
414}
415
416fn unmount_active(
417    inner: &Arc<Mutex<State_>>,
418    global_context: &Arc<GlobalContext>,
419    options: &ManagedRouterOptions,
420) {
421    let prev = {
422        let mut g = inner.lock().unwrap();
423        g.active.take()
424    };
425    let Some((idx, module)) = prev else { return };
426
427    // Resolve the route's persist flag (route override > router default).
428    let (key, persist) = {
429        let g = inner.lock().unwrap();
430        let route = &g.routes[idx];
431        (
432            route.cache_key(),
433            route.persist.unwrap_or(options.default_persist),
434        )
435    };
436
437    // Always deactivate first — `on_deactivated` runs before either path.
438    module.deactivate();
439
440    if persist {
441        // Cache + LRU bookkeeping. If we're at cap, evict the LRU entry
442        // (oldest) and destroy it.
443        let mut evictees: Vec<(String, Arc<dyn ManagedModule>)> = Vec::new();
444        {
445            let mut g = inner.lock().unwrap();
446            g.persisted.insert(key.clone(), module);
447            // Refresh recency: drop old entry then push.
448            g.lru.retain(|k| k != &key);
449            g.lru.push_back(key.clone());
450            while g.lru.len() > options.max_persisted_modules {
451                if let Some(oldest) = g.lru.pop_front() {
452                    if let Some(m) = g.persisted.remove(&oldest) {
453                        evictees.push((oldest, m));
454                    }
455                }
456            }
457        }
458        for (k, m) in evictees {
459            m.destroy();
460            global_context.unregister_module(&k);
461        }
462    } else {
463        module.destroy();
464        global_context.unregister_module(&key);
465    }
466}
467
468fn key_for_idx(inner: &Arc<Mutex<State_>>, idx: usize) -> String {
469    inner
470        .lock()
471        .unwrap()
472        .routes
473        .get(idx)
474        .map(|r| r.cache_key())
475        .unwrap_or_default()
476}