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 = Arc<dyn Fn() -> Result<Arc<dyn ManagedModule>> + Send + Sync>;
117
118/// A single route entry registered with [`ManagedRouter`].
119pub struct RouteDefinition {
120    /// Path pattern (`/`, `/users/:id`, `/api/*`).
121    pub path: String,
122    /// Component name — used as the persist-cache key (lowercased) and
123    /// surfaced in logs / `get_active_route()` for symmetry with the
124    /// other SDKs.
125    pub component: String,
126    /// Factory that builds a fresh module instance.
127    pub factory: ModuleFactory,
128    /// Whether the instance should be cached on `unmount` and reused on
129    /// revisit. `None` = inherit the [`ManagedRouter`]'s default
130    /// (currently `false`); `Some(true|false)` = explicit override.
131    pub persist: Option<bool>,
132}
133
134impl RouteDefinition {
135    /// Convenience constructor with the factory passed inline.
136    pub fn factory<F>(path: impl Into<String>, component: impl Into<String>, f: F) -> Self
137    where
138        F: Fn() -> Result<Arc<dyn ManagedModule>> + Send + Sync + 'static,
139    {
140        Self {
141            path: path.into(),
142            component: component.into(),
143            factory: Arc::new(f),
144            persist: None,
145        }
146    }
147
148    /// Override the router's default persist behavior for this route.
149    pub fn persist(mut self, persist: bool) -> Self {
150        self.persist = Some(persist);
151        self
152    }
153
154    fn cache_key(&self) -> String {
155        self.component.to_lowercase()
156    }
157}
158
159/// Tunables for [`ManagedRouter`]. Mirrors `ManagedRouterOptions` in the
160/// other SDKs.
161#[derive(Debug, Clone)]
162pub struct ManagedRouterOptions {
163    /// LRU cap on the persist cache. Once exceeded, the
164    /// least-recently-used entry is destroyed to make room. Defaults to
165    /// [`DEFAULT_PERSIST_CAP`].
166    pub max_persisted_modules: usize,
167    /// Default `persist` value when [`RouteDefinition::persist`] is
168    /// `None`. Matches the TS contract: opt-in (`false`).
169    pub default_persist: bool,
170}
171
172impl Default for ManagedRouterOptions {
173    fn default() -> Self {
174        Self {
175            max_persisted_modules: DEFAULT_PERSIST_CAP,
176            default_persist: false,
177        }
178    }
179}
180
181struct State_ {
182    routes: Vec<RouteDefinition>,
183    active: Option<(usize, Arc<dyn ManagedModule>)>,
184    persisted: HashMap<String, Arc<dyn ManagedModule>>,
185    /// Most-recently-used last. Kept in lock-step with `persisted`.
186    lru: VecDeque<String>,
187    unsub: Option<SubscriptionId>,
188}
189
190/// Orchestrates module mount/unmount on route changes.
191///
192/// See the module-level docs for the high-level lifecycle. Hold via
193/// `Arc<ManagedRouter>` if multiple owners need to drive it
194/// (`router.on_navigate` will keep an internal clone alive).
195pub struct ManagedRouter {
196    router: Arc<HypenRouter>,
197    global_context: Arc<GlobalContext>,
198    options: ManagedRouterOptions,
199    inner: Arc<Mutex<State_>>,
200}
201
202impl ManagedRouter {
203    pub fn new(
204        router: Arc<HypenRouter>,
205        global_context: Arc<GlobalContext>,
206        options: ManagedRouterOptions,
207    ) -> Self {
208        Self {
209            router,
210            global_context,
211            options,
212            inner: Arc::new(Mutex::new(State_ {
213                routes: Vec::new(),
214                active: None,
215                persisted: HashMap::new(),
216                lru: VecDeque::new(),
217                unsub: None,
218            })),
219        }
220    }
221
222    pub fn add_route(&self, route: RouteDefinition) -> &Self {
223        self.inner.lock().unwrap().routes.push(route);
224        self
225    }
226
227    /// Subscribe to the underlying router and mount the initial route.
228    ///
229    /// Idempotent — calling `start()` a second time without `stop()` is a
230    /// no-op (the existing subscription stays).
231    pub fn start(&self) {
232        // Subscribe first so any nav fired between this and the initial
233        // mount is observed.
234        {
235            let mut g = self.inner.lock().unwrap();
236            if g.unsub.is_some() {
237                return;
238            }
239            let inner = Arc::clone(&self.inner);
240            let router = Arc::clone(&self.router);
241            let context = Arc::clone(&self.global_context);
242            let options = self.options.clone();
243            let sub = self.router.on_navigate(move |_payload| {
244                let path = router.current_path();
245                handle_route_change(&inner, &context, &options, &path);
246            });
247            g.unsub = Some(sub);
248        }
249        let path = self.router.current_path();
250        handle_route_change(&self.inner, &self.global_context, &self.options, &path);
251    }
252
253    /// Unsubscribe and tear down both the active and persisted modules.
254    pub fn stop(&self) {
255        let (active, persisted, sub) = {
256            let mut g = self.inner.lock().unwrap();
257            let active = g.active.take().map(|(_, m)| m);
258            let persisted: Vec<_> = g.persisted.drain().collect();
259            g.lru.clear();
260            (active, persisted, g.unsub.take())
261        };
262        if let Some(sub) = sub {
263            self.router.off(sub);
264        }
265        if let Some(m) = active {
266            m.deactivate();
267            m.destroy();
268            self.global_context
269                .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
289            .as_ref()
290            .map(|(idx, _)| g.routes[*idx].path.clone())
291    }
292
293    /// Snapshot of currently-cached module keys (lowercase). Test-only
294    /// helper — exposed because the persist cache is otherwise private.
295    pub fn persisted_keys(&self) -> Vec<String> {
296        self.inner.lock().unwrap().lru.iter().cloned().collect()
297    }
298}
299
300impl Drop for ManagedRouter {
301    fn drop(&mut self) {
302        // Best-effort teardown so subscriptions don't outlive the router.
303        if self.inner.lock().unwrap().unsub.is_some() {
304            self.stop();
305        }
306    }
307}
308
309fn handle_route_change(
310    inner: &Arc<Mutex<State_>>,
311    global_context: &Arc<GlobalContext>,
312    options: &ManagedRouterOptions,
313    path: &str,
314) {
315    // Route resolution — done under lock against a snapshot of the
316    // routes vec to avoid holding the lock across factory invocation.
317    let matched_idx = {
318        let g = inner.lock().unwrap();
319        g.routes
320            .iter()
321            .position(|r| hypen_engine::match_path(&r.path, path).is_some())
322    };
323
324    let Some(idx) = matched_idx else {
325        unmount_active(inner, global_context, options);
326        return;
327    };
328
329    // Same-route nav is a no-op.
330    {
331        let g = inner.lock().unwrap();
332        if let Some((active_idx, _)) = &g.active {
333            if *active_idx == idx {
334                return;
335            }
336        }
337    }
338
339    // Pull the target from cache *before* unmounting the previous route.
340    // Otherwise, when the previous route is itself persisted and the
341    // cache is at capacity, the LRU eviction in `unmount_active` would
342    // evict the very entry we're about to restore — silently turning
343    // the cache hit into a fresh construction.
344    let preloaded = {
345        let mut g = inner.lock().unwrap();
346        let key = g.routes[idx].cache_key();
347        g.persisted.remove(&key).map(|m| {
348            g.lru.retain(|k| k != &key);
349            m
350        })
351    };
352    unmount_active(inner, global_context, options);
353    mount_route(inner, global_context, idx, preloaded);
354}
355
356fn mount_route(
357    inner: &Arc<Mutex<State_>>,
358    global_context: &Arc<GlobalContext>,
359    idx: usize,
360    preloaded: Option<Arc<dyn ManagedModule>>,
361) {
362    // Decide cache-hit vs. fresh construction under lock. The factory
363    // (which may do non-trivial work) always runs outside the lock.
364    enum Plan {
365        Hit(Arc<dyn ManagedModule>),
366        Miss { key: String, factory: ModuleFactory },
367    }
368    let plan = if let Some(m) = preloaded {
369        Plan::Hit(m)
370    } else {
371        let g = inner.lock().unwrap();
372        let key = g.routes[idx].cache_key();
373        let factory = Arc::clone(&g.routes[idx].factory);
374        Plan::Miss { key, factory }
375    };
376
377    let module = match plan {
378        Plan::Hit(m) => {
379            // Cache hit — re-record the GlobalContext registration was
380            // never removed (persist branch in unmount keeps it), so we
381            // only need to flip the active slot.
382            let mut g = inner.lock().unwrap();
383            g.active = Some((idx, Arc::clone(&m)));
384            drop(g);
385            m.activate();
386            return;
387        }
388        Plan::Miss { key, factory } => match (factory)() {
389            Ok(m) => {
390                // Register in GlobalContext (matches every other SDK —
391                // siblings can read this module's state via
392                // `context.get_module_state(name)`).
393                global_context.register_module_state(&key, serde_json::Value::Null);
394                let mut g = inner.lock().unwrap();
395                g.active = Some((idx, Arc::clone(&m)));
396                drop(g);
397                m
398            }
399            Err(e) => {
400                // Mirror Swift / Kotlin: log and clear the active slot.
401                // No `log` crate dep here, so use stderr.
402                eprintln!(
403                    "[hypen-server] managed_router: factory for component '{}' failed: {e}",
404                    key_for_idx(inner, idx)
405                );
406                let mut g = inner.lock().unwrap();
407                g.active = None;
408                return;
409            }
410        },
411    };
412
413    // First-time mount: fire on_created (via `mount`) then on_activated.
414    module.mount();
415    module.activate();
416}
417
418fn unmount_active(
419    inner: &Arc<Mutex<State_>>,
420    global_context: &Arc<GlobalContext>,
421    options: &ManagedRouterOptions,
422) {
423    let prev = {
424        let mut g = inner.lock().unwrap();
425        g.active.take()
426    };
427    let Some((idx, module)) = prev else { return };
428
429    // Resolve the route's persist flag (route override > router default).
430    let (key, persist) = {
431        let g = inner.lock().unwrap();
432        let route = &g.routes[idx];
433        (
434            route.cache_key(),
435            route.persist.unwrap_or(options.default_persist),
436        )
437    };
438
439    // Always deactivate first — `on_deactivated` runs before either path.
440    module.deactivate();
441
442    if persist {
443        // Cache + LRU bookkeeping. If we're at cap, evict the LRU entry
444        // (oldest) and destroy it.
445        let mut evictees: Vec<(String, Arc<dyn ManagedModule>)> = Vec::new();
446        {
447            let mut g = inner.lock().unwrap();
448            g.persisted.insert(key.clone(), module);
449            // Refresh recency: drop old entry then push.
450            g.lru.retain(|k| k != &key);
451            g.lru.push_back(key.clone());
452            while g.lru.len() > options.max_persisted_modules {
453                if let Some(oldest) = g.lru.pop_front() {
454                    if let Some(m) = g.persisted.remove(&oldest) {
455                        evictees.push((oldest, m));
456                    }
457                }
458            }
459        }
460        for (k, m) in evictees {
461            m.destroy();
462            global_context.unregister_module(&k);
463        }
464    } else {
465        module.destroy();
466        global_context.unregister_module(&key);
467    }
468}
469
470fn key_for_idx(inner: &Arc<Mutex<State_>>, idx: usize) -> String {
471    inner
472        .lock()
473        .unwrap()
474        .routes
475        .get(idx)
476        .map(|r| r.cache_key())
477        .unwrap_or_default()
478}