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(¬es)
}
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_asfor interfaces andbind_lazyfor services that need others; resolve withctx.get.- A provider's
registerbinds, itsbootresolves. - 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::broadcastjoins the two.