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. The example splits the wiring by target:
native runs the middleware on a background thread; WebAssembly skips it because
this middleware is thread::spawn based.
pub fn new(shell: Arc<dyn CruxShell>) -> Self {
// Native: RngMiddleware handles `Random` in a background task, so its
// effects are delivered to the shell asynchronously via the callback.
#[cfg(not(target_family = "wasm"))]
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}"),
});
// Wasm: no background tasks, so effects are returned synchronously from
// update/resolve. The callback is wired for API parity but unused.
#[cfg(target_family = "wasm")]
let core =
Core::<Counter>::new().bridge::<BincodeFfiFormat>(
move |effect_bytes| match effect_bytes {
Ok(effect) => shell.process_effects(effect),
Err(e) => panic!("{e}"),
},
);
Self { core }
}
The native branch 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.Randomeffects are intercepted and resolved here; everything else passes through..map_effect::<Effect>()— remaps to the FFI-facingEffectenum used by typegen and the bridge. In this example the FFIEffectmirrors the app's, so this is a 1:1 remap. This is also where you can narrow the effect type: drop variants that middleware fully consumes from the FFI enum and panic on them inFrom, so the shell never sees them. The counter example doesn't narrowRandombecause the WebAssembly shells handle it themselves..bridge::<BincodeFfiFormat>(...)— creates the FFI bridge as usual.
On WebAssembly there is no middleware: the bridge wraps Core directly, so
Random effects flow straight through to the shell, which fulfills them itself.
The FFI effect type
The FFI module declares its own Effect enum. This is the enum typegen turns
into shell types, and the bridge serializes it:
#[effect(facet_typegen)]
pub enum Effect {
Render(RenderOperation),
Http(HttpRequest),
ServerSentEvents(SseRequest),
Random(RandomNumberRequest),
}
A From implementation converts from the app's full effect type. In this example the
two enums have the same variants, so every arm remaps 1:1:
impl From<crate::app::Effect> for Effect {
fn from(effect: crate::app::Effect) -> Self {
match effect {
crate::Effect::Render(request) => Self::Render(request),
crate::Effect::Http(request) => Self::Http(request),
crate::Effect::ServerSentEvents(request) => Self::ServerSentEvents(request),
crate::Effect::Random(request) => Self::Random(request),
}
}
}
If a middleware fully consumes a variant on every target you build, you can
remove that variant from this enum and panic! on it in From — the shell then
sees a narrower set of effects. The counter example keeps Random here because
the WebAssembly shells trigger it themselves and need it in the typegen.
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.