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}