Chapter 4 of 15

The container, providers, middleware and events

Freddy's commands start to need things: a configuration, a way to export, a log of who deleted what. In Laravel he would reach for the container, a service provider, middleware and events. Elyra has all four, with the same names.

The container and Ctx

Container is a type-keyed registry of shared singletons. Freddy rarely touches it directly: he binds with App::bind or in a provider, and resolves through Ctx:

App::new()
    .bind(Config { greeting: "hi".into() })
    .commands(commands![greet]);

#[command]
async fn greet(ctx: Ctx, name: String) -> String {
    let cfg = ctx.get::<Config>();        // Arc<Config>; panics if unbound
    format!("{} {name}", cfg.greeting)
}

get panics with a clear message when nothing is bound, because a missing binding is a wiring bug and should fail loudly. try_get returns an Option, and has asks without building anything. Bindings must be Send + Sync + 'static, since commands run on tokio, and a later bind of the same type replaces the earlier one.

Binding an interface

Freddy's export can write Markdown files today and might upload somewhere tomorrow. Laravel's bind(Interface::class, Impl::class) is bind_as, under a trait object:

trait Exporter: Send + Sync { fn export(&self, notes: &[Note]) -> Result<()>; }

App::new().bind_as::<dyn Exporter>(Arc::new(MarkdownExporter::new()));

#[command]
async fn export_all(ctx: Ctx) -> Result<()> {
    let notes = /* ... */;
    ctx.get::<dyn Exporter>().export(&notes)
}

Exporter and MarkdownExporter are Freddy's; the pattern is the guide's, which uses a Mailer. Swapping the implementation, a fake in a test for instance, happens in one place and never touches a command.

Providers, in two phases

A Provider is Laravel's ServiceProvider, and it has the same two phases. register binds services and runs for every provider first, so it must not resolve anything; boot runs after everything is registered, with a populated context:

struct ConfigProvider;

impl Provider for ConfigProvider {
    fn register(&self, c: &mut Container) {
        c.bind(Config { greeting: "hi".into() });
    }
    fn boot(&self, ctx: &Ctx) {
        let _ = ctx.get::<Config>();   // the container is complete here
    }
}

App::new().provider(ConfigProvider).run();

When a service needs another one to be built, bind_lazy builds it the first time it is resolved, after every provider has registered, so the order of providers stops mattering. A cycle between lazy bindings panics with the chain instead of deadlocking. The container page lists the full lifecycle, from the first bind to the window opening.

Middleware around every command

Middleware wraps command dispatch in an onion, so logging, timing, auth and rate limiting live in one place instead of in every command. A middleware calls next.run(ctx, req) to continue, or returns its own result to stop the call. Global middleware runs in registration order, outermost first:

App::new().middleware(Timing).commands(commands![..]).run();

Freddy wants an audit line only when a note is deleted. That is per-command middleware, Laravel's middleware aliases:

App::new()
    .middleware_alias("audit", AuditLog::new())
    .commands(commands![delete_note]);

#[command(middleware = ["audit"])]
async fn delete_note(ctx: Ctx, id: i64) -> Result<()> { /* ... */ }

Two details from the middleware page are worth keeping. An unknown name stops the app at startup, because a misspelt name must never mean a command runs without its auth check. And middleware is not the same as an ability from chapter 3: an ability decides whether the page may call a command at all, while middleware wraps every call, from the page or from Rust. A request also says who is calling, the app's own webview or an AI agent over MCP, which chapter 10 comes back to.

The EventBus: Rust pushes to the page

The EventBus is Elyra's Broadcasting. Rust owns the state, and the page subscribes to changes instead of polling. Emitting is non-blocking and works from any thread:

ctx.get::<EventBus>().emit("export:progress", &42u32).ok();

On the page, channel(name) returns a Svelte-readable store, so Freddy's progress bar is just $progress:

<script>
  import { channel } from "@elyra/runtime";
  const progress = channel("export:progress");
</script>
<progress value={$progress} max="100"></progress>

Emits accumulate and flush together over one long-poll, so many changes cost one round trip. Every window gets its own queue, so a second window does not steal the first one's events. A window that opens later gets no replay of what it missed, so a new window should ask a command for the current state.

Domain events, and broadcasting them

For events inside the Rust side, where one part announces something and others react without the announcer knowing who they are, there is the Dispatcher: Laravel's Event::dispatch and listeners. Listeners run in registration order, one at a time, and the first error stops the chain and fails the command that dispatched. dispatch_background runs the chain on its own task instead.

App::broadcast is Laravel's ShouldBroadcast: it forwards every dispatched event of a type to the EventBus and declares the channel's type for codegen, so the page gets a typed channel:

#[derive(Clone, serde::Serialize, specta::Type)]
struct NoteArchived { note_id: i64 }

App::new().broadcast::<NoteArchived>("notes:archived");

The Rust side dispatches a domain event and never mentions the webview. Elyra dispatches some events of its own, too: from a Laravel backend, when an offline write is sent or refused, and for Laravel's notifications. Freddy will listen to all of them in chapters 12 to 14. The details are on the events page.

What you learned

  • App::bind, bind_as for interfaces and bind_lazy for services that need others; resolve with ctx.get.
  • A provider's register binds, its boot resolves.
  • Middleware wraps every command; aliases and groups attach it to some, and an unknown name stops the app at startup.
  • The EventBus pushes values to the page as Svelte stores; the Dispatcher carries domain events inside Rust, and App::broadcast joins the two.
Next: in Chapter 5 Freddy gives his notes a database.