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}