Middleware
Middleware is a relatively new, and somewhat advanced feature for split effect handling, i.e. handling some effects in the shell, and some still in the core, but outside the app's state loop.
Middleware can be useful when you have an existing 3rd party library written in Rust which you want to use, but it isn't written in a sans-I/O way with managed effects or otherwise isn't compatible with Crux. This is sadly most libraries with side effects.
It is quite likely most apps will never need to use middleware. Before reaching for middleware, we encourage you to consider:
- Implementing the side-effect in each Shell using native, platform SDKs. Shared libraries give a productivity boost at first, but for the same reason Crux uses Capabilities, they can't always be the best platform citizens, and often rely on very low-level system APIs which compromise the experience, don't collaborate well with platform security measures, etc.
- Moving coordination logic from the Rust implementation into a custom capability in the core and implementing it on top of lower level capabilities, e.g. HTTP. This would be the case for HTTP API SDK type libraries, but may well not be practical at first
Only if neither of these is a good option, reach for a middleware. The cost of using it is that the effect handling becomes less straightforward, which may cause some headaches debugging effect ordering, etc.
We are also still learning how middleware operates in the wild, and the API may change more than the rest of Crux tends to.
All that said, the feature is used in production with success today and should work well.
How it works
Middleware sits between the Core and the Shell in the effect processing pipeline. When the app requests effects, they pass through the middleware stack on their way to the shell. A middleware layer can intercept specific effect variants, handle them (performing the side-effect in Rust), and resolve the request — all without the shell ever seeing that effect. Effects the middleware doesn't handle pass through to the shell as normal.
We'll walk through the counter-middleware example to see how this works in practice. This example is a counter app that has a "random" button — when pressed, the counter changes by a random amount. The random number generation is handled by a middleware, rather than by the shell.
Defining the operation
First, we need an Operation type that describes the request and its output. This is the
same as defining a capability's protocol — a request type and a response type:
#[derive(Facet, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RandomNumberRequest(pub isize, pub isize); // request a random number from 1 to N, inclusive
#[derive(Facet, Debug, PartialEq, Eq, Deserialize)]
pub struct RandomNumber(pub isize);
impl Operation for RandomNumberRequest {
type Output = RandomNumber;
}
The RandomNumberRequest carries the range (min, max), and RandomNumber carries the result.
The Operation impl connects them so that Crux knows a RandomNumberRequest produces a
RandomNumber.
The app uses this operation as one variant of its Effect enum:
#[effect(facet_typegen)]
#[derive(Debug)]
pub enum Effect {
Render(RenderOperation),
Http(HttpRequest),
ServerSentEvents(SseRequest),
Random(RandomNumberRequest),
}
And the app can request a random number using Command::request_from_shell, just as it
would for any shell-handled effect:
Event::Random => Command::request_from_shell(RandomNumberRequest(-5, 5))
.map(|out| out.0)
.then_send(Event::UpdateBy),
The app doesn't know or care that this effect will be intercepted by middleware — it just requests the effect and handles the response.
Implementing EffectMiddleware
The EffectMiddleware trait is how you tell Crux what to do when it encounters a specific
effect. You implement try_process_effect, which receives the operation and an
EffectResolver that you use to send back the result.
Here's the RngMiddleware from the example:
use std::{
sync::mpsc::{Sender, channel},
thread::spawn,
};
use crux_core::middleware::{EffectMiddleware, EffectResolver};
use rand::rngs::SysRng;
use rand::{RngExt, SeedableRng, TryRng as _, rngs::StdRng};
use crate::capabilities::{RandomNumber, RandomNumberRequest};
pub struct RngMiddleware {
jobs_tx: Sender<(RandomNumberRequest, EffectResolver<RandomNumber>)>,
}
impl RngMiddleware {
pub fn new() -> Self {
let (jobs_tx, jobs_rx) = channel::<(RandomNumberRequest, EffectResolver<RandomNumber>)>();
// Persistent background worker
spawn(move || {
let mut sys_rng = SysRng;
let mut rng =
StdRng::seed_from_u64(sys_rng.try_next_u64().expect("could not seed RNG"));
while let Ok((RandomNumberRequest(from, to), mut resolver)) = jobs_rx.recv() {
#[allow(clippy::cast_sign_loss)]
let top = (to - from) as usize;
#[allow(clippy::cast_possible_wrap)]
let out = rng.random_range(0..top) as isize + from;
resolver.resolve(RandomNumber(out));
}
});
Self { jobs_tx }
}
}
impl EffectMiddleware for RngMiddleware {
type Op = RandomNumberRequest;
fn try_process_effect(
&self,
operation: RandomNumberRequest,
resolver: EffectResolver<RandomNumber>,
) {
self.jobs_tx
.send((operation, resolver))
.expect("Job failed to send to worker thread");
}
}
A few things to note:
- The
type Opassociated type tells Crux which operation this middleware handles (RandomNumberRequestin this case). try_process_effectreceives the operation and anEffectResolver. You must callresolver.resolve(output)with the result when the work is done.- The processing happens on a background thread. This is important — the middleware
must not block the caller of
process_event. On native targets this typically means spawning a thread; on WASM it means an async task (e.g.spawn_local). - The background thread pattern shown here (a persistent worker with a channel) is a good approach when the middleware holds state (like the RNG seed). For stateless work, you could simply spawn a thread per request.
Wiring it up
The middleware is composed with the Core in the FFI module, where you build the bridge between the core and the shell. Here's the key part from the uniffi (native) FFI setup:
pub fn new(shell: Arc<dyn CruxShell>) -> Self {
let core = Core::<Counter>::new()
.handle_effects_using(RngMiddleware::new())
.map_effect::<Effect>()
.bridge::<BincodeFfiFormat>(move |effect_bytes| match effect_bytes {
Ok(effect) => shell.process_effects(effect),
Err(e) => panic!("{e}"),
});
Self { core }
}
This reads bottom-to-top as a pipeline:
Core::<Counter>::new()— creates the core, which produces the app's fullEffectenum (including theRandomvariant)..handle_effects_using(RngMiddleware::new())— wraps the core with the RNG middleware. AnyRandomeffects are intercepted and handled here; all other effects pass through..map_effect::<Effect>()— narrows the effect type. Since the middleware has consumed allRandomeffects, the shell will never see them. This step converts to a newEffectenum that doesn't include theRandomvariant, so your shell code doesn't need an unreachable branch..bridge::<BincodeFfiFormat>(...)— creates the FFI bridge as usual.
The narrowed effect type
The FFI module defines its own Effect enum without the Random variant:
#[effect(facet_typegen)]
pub enum Effect {
Render(RenderOperation),
Http(HttpRequest),
ServerSentEvents(SseRequest),
}
And a From implementation to convert from the app's full effect type:
impl From<crate::app::Effect> for Effect {
fn from(effect: crate::app::Effect) -> Self {
match effect {
crate::Effect::Render(request) => Effect::Render(request),
crate::Effect::Http(request) => Effect::Http(request),
crate::Effect::ServerSentEvents(request) => Effect::ServerSentEvents(request),
crate::Effect::Random(_) => panic!("Encountered a Random effect"),
}
}
}
The Random arm panics because it should never be reached — the middleware handles all
Random effects before they get here.
Testing
The app can be tested exactly the same way as any other Crux app — the middleware is not
involved in unit tests. You test the app's update function directly, treating Random
as a normal effect:
#[test]
fn random_change() {
let app = Counter;
let mut model = Model::default();
let mut cmd = app.update(Event::Random, &mut model);
// the app should request a random number from the web API
let mut request = cmd.effects().next().unwrap().expect_random();
assert_eq!(request.operation, RandomNumberRequest(-5, 5));
request.resolve(RandomNumber(-2)).unwrap();
// And start an UpdateBy the number
let event = cmd.events().next().unwrap();
assert_eq!(event, Event::UpdateBy(-2));
This is one of the nice properties of middleware: the app logic remains pure and testable, and the middleware is a separate concern that's composed at the FFI boundary.
Summary
To add a middleware to your app:
- Define an
Operation— a request type and output type, just like a capability protocol. - Implement
EffectMiddleware— handle the operation and resolve the result, typically on a background thread. - Wire it up — use
.handle_effects_using()in your FFI setup to intercept the effects, and optionally.map_effect()to narrow the effect type for the shell.
For the full API reference, see the middleware module docs.