crux_core/effects/mod.rs
1//! Support for routing effects to explicit, type-based handlers.
2//!
3//! This module enables an advanced use-case, where some effects are not
4//! handled by the shell using the standard serialization-based FFI interface.
5//! Instead the core FFI can be extended with core-side effect processing or
6//! custom effect handling FFI APIs, handling the operations and outputs using
7//! a different data exchange method (e.g. raw pointers, zero-copy formats like
8//! Cap'n Proto, etc.)
9//!
10//! # Overview
11//!
12//! The entry point is the [`EffectRouter`], which wraps a [`Core`] and a
13//! routing closure. The closure inspects each [`Effect`](crate::App::Effect)
14//! the app emits and dispatches it to the appropriate handler, or "lane". Crucially,
15//! the follow-up effects produced when a request is resolved are routed back
16//! through the same closure, so the same policy applies for the whole lifetime
17//! of a chain of effects.
18//!
19//! The available lanes live in the [`routes`] module:
20//!
21//! - [`Serialized`](routes::Serialized) keeps the standard, bridge-like
22//! behaviour: effects are serialized to bytes, sent to the shell, and
23//! resolved by id with serialized responses. This is the default lane and the
24//! primary onboarding path; it typically acts as the fall-through arm of the
25//! routing closure.
26//! - [`Parked`](routes::Parked) supports payloads and results that are awkward
27//! or undesirable to serialize (for example opaque pointer-style handles),
28//! using a custom, user-owned FFI. The request is parked under an
29//! [`EffectId`] which the shell passes back when resolving.
30//! - [`Buffer`](routes::Buffer) collects requests for the caller to drain and
31//! handle synchronously, which is useful in tests and simple in-process
32//! handlers.
33//!
34//! Effects can also be handled entirely inside the core by a Rust handler
35//! (including async or background work) that resolves requests back through the
36//! router via the [`ResolveSink`] trait.
37//!
38//! # Wiring it up
39//!
40//! Routes are grouped in a user-defined type that implements [`Routes`]. The
41//! router is created with [`EffectRouter::new`], which hands the constructed
42//! route set to a builder closure so it can be captured by the routing closure.
43//! Because routes need to resolve effects back through the router, they hold a
44//! [`Weak`] reference to it, and the router is therefore stored behind an
45//! [`Arc`].
46//!
47//! See the `effect_router_prototype` integration test in `crux_core` for a
48//! complete, worked example, and `docs/src/rfcs/effect-router.md` for the
49//! design rationale.
50
51mod registry;
52pub mod routes;
53
54use std::sync::{Arc, Weak};
55
56use crate::{Core, Request, Resolvable, ResolveError, capability::Operation};
57
58pub use registry::EffectId;
59
60/// Wraps a [`Core`] and routes each emitted effect to a type-specific handler.
61///
62/// The router owns the set of routes ([`RouteSet`](Routes)) and a routing closure which
63/// decides, per effect, which handler should process it. Any follow-up effects
64/// produced while resolving a request are passed back through the same closure,
65/// so routing decisions stay consistent across an entire chain of effects.
66///
67/// Construct one with [`EffectRouter::new`]. The router is always held behind an
68/// [`Arc`] so that individual routes can keep a [`Weak`] reference back to it
69/// and drive the runtime forward when they resolve requests.
70pub struct EffectRouter<App, RouteSet>
71where
72 App: crate::App,
73{
74 /// The set of handlers effects are routed to.
75 ///
76 /// Exposed so the surrounding FFI type can reach individual routes (for
77 /// example to resolve a parked or serialized request by id).
78 pub routes: RouteSet,
79 core: Core<App>,
80 route_effects: Box<dyn Fn(App::Effect) + Send + Sync>,
81}
82
83/// A set of effect handlers ("routes") owned by an [`EffectRouter`].
84///
85/// Implement this on a type that groups together the individual routes your app
86/// needs (for example a [`Serialized`](routes::Serialized) lane plus one or more
87/// [`Parked`](routes::Parked) lanes and core-local handlers). The router calls
88/// [`Routes::new`] while it is being constructed, handing over a [`Weak`]
89/// reference to itself so each route can later resolve requests and advance the
90/// runtime.
91///
92/// The type must be [`Clone`] because a clone is given to the routing closure
93/// built in [`EffectRouter::new`]; routes are typically wrapped in [`Arc`] so
94/// cloning is cheap and shares the same underlying handlers.
95pub trait Routes<App>: Sized + Clone
96where
97 App: crate::App,
98{
99 /// Construct the route set, given a [`Weak`] handle to the router that will
100 /// own it.
101 ///
102 /// The handle is `Weak` to avoid a reference cycle with the [`Arc`] holding
103 /// the router; routes upgrade it on demand when they need to resolve a
104 /// request.
105 fn new(router: Weak<EffectRouter<App, Self>>) -> Self;
106}
107
108impl<App, RouteSet> EffectRouter<App, RouteSet>
109where
110 App: crate::App,
111 RouteSet: Routes<App> + Send + Sync + 'static,
112{
113 /// Create a new router wrapping `core`.
114 ///
115 /// The route set is constructed first (via [`Routes::new`]) and then passed
116 /// to `route_effects_builder`, which returns the routing closure. Splitting
117 /// construction this way lets the closure capture the routes it needs to
118 /// dispatch to, while the routes themselves hold a [`Weak`] reference back
119 /// to the router so they can resolve requests later.
120 ///
121 /// The router is returned inside an [`Arc`] because that shared ownership is
122 /// what the routes' weak references point at; see [`Arc::new_cyclic`].
123 pub fn new<F, R>(core: Core<App>, route_effects_builder: F) -> Arc<Self>
124 where
125 F: FnOnce(RouteSet) -> R,
126 R: Fn(App::Effect) + Send + Sync + 'static,
127 {
128 Arc::new_cyclic(|weak| {
129 let routes = RouteSet::new(weak.clone());
130 let route_effects = route_effects_builder(routes.clone());
131
132 Self {
133 core,
134 routes,
135 route_effects: Box::new(route_effects),
136 }
137 })
138 }
139
140 /// Process an event from the shell and route every resulting effect.
141 ///
142 /// This is the router's equivalent of [`Core::process_event`]: it forwards
143 /// the event to the wrapped core and passes each emitted effect through the
144 /// routing closure.
145 pub fn update(&self, event: App::Event) {
146 for effect in self.core.process_event(event) {
147 (self.route_effects)(effect);
148 }
149 }
150
151 /// Resolve an effect [`Request`] with an `output` value.
152 ///
153 /// # Errors
154 ///
155 /// Returns an error if the request is not expected to be resolved by
156 /// the underlying [`Core`].
157 pub fn resolve<Output>(
158 &self,
159 request: &mut impl Resolvable<Output>,
160 output: Output,
161 ) -> Result<(), ResolveError> {
162 for effect in self.core.resolve(request, output)? {
163 (self.route_effects)(effect);
164 }
165
166 Ok(())
167 }
168
169 /// Return the current view model from the wrapped [`Core`].
170 pub fn view(&self) -> App::ViewModel {
171 self.core.view()
172 }
173
174 /// Advance the core's effect runtime and route any follow-up effects.
175 ///
176 /// Routes call this after resolving a request whose `Output` type has been
177 /// erased (so they cannot call [`EffectRouter::resolve`] directly): the
178 /// request is resolved against the route's own registry, and this drives the
179 /// runtime forward to collect and route the resulting effects.
180 fn process(&self) {
181 for effect in self.core.process() {
182 (self.route_effects)(effect);
183 }
184 }
185}
186
187/// Lets a core-local handler resolve a [`Request`] back through the router.
188///
189/// Core-side handlers (for example background workers that do async I/O) hold a
190/// [`Weak`] reference to something implementing this trait, typically the
191/// [`EffectRouter`] itself. When a handler finishes its work, it calls
192/// [`ResolveSink::resolve_request`] to resolve the original request and advance
193/// the runtime, so any follow-up effects are routed using the same policy.
194///
195/// The trait is generic over the [`Operation`] `Op` so a handler only depends on
196/// the single operation type it knows how to service, rather than the whole
197/// route set.
198pub trait ResolveSink<Op>
199where
200 Op: Operation,
201{
202 /// Resolve a [`Request`] with an `output` value and advance the runtime.
203 ///
204 /// # Errors
205 ///
206 /// Returns an error if the request is not expected to be resolved by
207 /// the underlying [`Core`].
208 fn resolve_request(
209 &self,
210 request: &mut Request<Op>,
211 output: Op::Output,
212 ) -> Result<(), ResolveError>;
213}
214
215impl<App, RouteSet, Op> ResolveSink<Op> for EffectRouter<App, RouteSet>
216where
217 App: crate::App,
218 RouteSet: Routes<App> + Send + Sync + 'static,
219 Op: Operation,
220{
221 fn resolve_request(
222 &self,
223 request: &mut Request<Op>,
224 output: Op::Output,
225 ) -> Result<(), ResolveError> {
226 self.resolve(request, output)?;
227 self.process();
228
229 Ok(())
230 }
231}