Crux Bridge
krucyfiks apps can run inside a Crux App without moving business logic into
Crux. The bridge is useful when an existing shell or organization already uses
Crux, but new domain code wants to stay in ordinary async Rust with
krucyfiks capabilities.
The counter example has a small proof of concept in counter_app_crux. It keeps
the business logic in counter_app, reuses the composed protocol from
counter_app_tea, and only adds a Crux-facing adapter crate.
Two Different Effect Types
The most important detail is that Crux has two effect-shaped values:
#![allow(unused)]
fn main() {
AppEffect
}
is the krucyfiks protocol. It is the operation payload sent to the shell. It implements:
#![allow(unused)]
fn main() {
krucyfiks::Effect
crux_core::capability::Operation<Output = AppEffectOutput>
}
The Crux app still needs a shell effect type:
#![allow(unused)]
fn main() {
#[crux_core::macros::effect]
pub enum Effect {
App(AppEffect),
}
}
After Crux expands this enum, the actual variant stores a
crux_core::Request<AppEffect>, not a plain AppEffect. That request contains
the resolve handle the shell later passes to Core::resolve.
This is why the wrapper enum is not just ceremony. AppEffect is the operation
data. Effect is the Crux shell envelope that remembers how to resume the
suspended command.
A single-operation app could write an equivalent newtype manually, but the Crux macro is the conventional spelling and also gives the usual Crux test helpers.
Opting In
Add the crux option to the composed krucyfiks protocol:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
#[krucyfiks(crux)]
pub enum AppEffect {
Account(AccountEffect),
Counter(CounterEffect),
}
}
This generates the Crux operation impl:
#![allow(unused)]
fn main() {
impl crux_core::capability::Operation for AppEffect {
type Output = AppEffectOutput;
}
}
The crate using this option must depend on krucyfiks_crux. The core
krucyfiks crate does not depend on Crux.
If Crux support should be optional, gate the attribute the same way you would gate any other derive-helper attribute:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
#[cfg_attr(feature = "crux", krucyfiks(crux))]
pub enum AppEffect {
Account(AccountEffect),
Counter(CounterEffect),
}
}
Then make krucyfiks_crux an optional dependency and expose a matching feature:
[features]
crux = ["dep:krucyfiks_crux"]
[dependencies]
krucyfiks_crux = { path = "../../krucyfiks_crux", optional = true }
With the feature enabled, AppEffect implements Crux Operation. Without it,
the protocol remains a plain krucyfiks protocol with no Crux dependency.
#[krucyfiks(crux)] works when the output type is determined by the effect type
itself. Output-only generic contexts are rejected because Crux
Operation::Output cannot depend on type parameters that do not appear in the
operation type.
The Crux Model
The bridge model owns the krucyfiks app and the channel that receives pending effects:
#![allow(unused)]
fn main() {
pub struct Model {
app: Arc<TeaApp>,
effects: EffectChannelHandler<AppEffect>,
}
}
The default model wires the app with an EffectChannel:
#![allow(unused)]
fn main() {
let (effects, handler) = EffectChannel::unbounded();
Self {
app: Arc::new(TeaApp::new(effects)),
effects: handler,
}
}
The Crux model is mostly an adapter. The domain state still lives inside the krucyfiks app.
Updating
Crux update is synchronous, but it returns a Command. The command is where
the async krucyfiks update runs:
#![allow(unused)]
fn main() {
fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
let app = model.app.clone();
let effects = model.effects.clone();
Command::drive_krucyfiks(async move { app.update(event).await }, effects)
}
}
Command::drive_krucyfiks comes from krucyfiks_crux::KrucyfiksCommandExt.
It runs two things together:
- the krucyfiks
updatefuture; - the
EffectChannelHandlerwaiting for pending krucyfiks effects.
Whenever the app awaits a capability call, the helper forwards that pending request to Crux:
#![allow(unused)]
fn main() {
ctx.request_from_shell(pending.request.clone()).await
}
The Crux shell resolves the request with AppEffectOutput. The helper then
responds to the original PendingEffect, which lets the krucyfiks update
continue.
No block_on, manual polling, or synthetic PollMe effect is needed. Crux
already knows how to drive async commands.
Viewing
The Crux view simply delegates:
#![allow(unused)]
fn main() {
fn view(&self, model: &Model) -> ViewModel {
model.app.view()
}
}
This means Crux shells can keep their normal Core::view() workflow while the
domain app remains a krucyfiks app.
Testing
The Crux tests handle the same protocol a normal krucyfiks boundary would see. For example, starting login emits account render:
#![allow(unused)]
fn main() {
let effects = core.process_event(start_login());
assert!(matches!(
effects.as_slice(),
[Effect::App(request)]
if matches!(
request.operation,
AppEffect::Account(AccountEffect::Render(RenderEffect::Render))
)
));
}
Resolving the Crux request resumes the krucyfiks update:
#![allow(unused)]
fn main() {
match effect {
Effect::App(mut request) => {
core.resolve(&mut request, account_render_done()).unwrap();
}
}
}
The random counter flow demonstrates a longer sequence: Crux receives
Counter(Random(GetNumber)), resolves it with 42, then receives
Counter(Render).
What This Bridge Does Not Do
The bridge does not make krucyfiks depend on Crux. It also does not require
Crux middleware, generated typegen, or changes to the domain app.
The boundary remains explicit:
counter_appowns business logic;counter_app_teaowns the krucyfiks protocol;counter_app_cruxowns the Crux shell adapter.
That separation is the point. A team can keep existing Crux shells running while
new or extracted domain code uses krucyfiks directly.