Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 update future;
  • the EffectChannelHandler waiting 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_app owns business logic;
  • counter_app_tea owns the krucyfiks protocol;
  • counter_app_crux owns 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.