Skip to content

Event Bus & Events

This page is the lookup reference for the public event types in plugin_kit. It assumes you already understand the model from the concept pages and want to confirm a signature, a return type, or a default value.

The bus is exported from the package barrel via src/event_bus.dart, alongside the convenience extensions in src/plugin/plugin.dart.

void demonstrateStandaloneEventBus() {
final bus = EventBus();
bus.on<UserMessage>((env) {
print('received: ${env.event.text}');
});
bus.bind((obs) => print('binding saw: ${obs.event.runtimeType}'));
}

Construct with the zero-argument constructor when you need a standalone bus (tests, custom hosts). In application code you almost never instantiate one yourself: each PluginRuntime owns one global bus and each PluginSession owns its own session bus. See Runtime and Sessions for who creates which.

dispose() clears every event handler, request handler, and bind callback, then flips isDisposed to true. The runtime calls it when a session ends or when the runtime itself is shut down. Calling it yourself on a runtime-owned bus is almost always a bug.

Every handler receives an envelope, not a raw payload. emit and emitInternal also return one.

void demonstrateEventEnvelope(EventEnvelope<UserMessage> envelope) {
// Read the payload.
print(envelope.event.text);
// Mutate it for downstream handlers.
envelope.event = UserMessage(text: envelope.event.text.toUpperCase());
// Stop the cascade with a final value.
envelope.stop(const UserMessage(text: '[stopped]'));
print('Stopped: ${envelope.stopped}');
}
  • event is mutable. Mutating it in a handler is how you transform the payload for the rest of the cascade.
  • identifier is whatever was passed to emit (or request). It is read-only after construction.
  • stop(value) halts the cascade, sets event to value, and flips stopped to true. Subsequent handlers in the priority chain do not run.
  • stopped is the boolean the emitter checks on the returned envelope to see whether someone short-circuited the chain.

The bus distinguishes user-emitted events (visible to bind callbacks) from internal-emitted events (skipped by bind) via the internal parameter on emit / emitSync, not via the envelope’s runtime type. All emit paths construct a plain EventEnvelope<T>; nothing else to construct.

EventSubscription on<T>(
EventHandler<T> handler, {
int priority = Priority.normal,
String? identifier,
});
EventSubscription onSync<T>(
SyncEventHandler<T> handler, {
int priority = Priority.normal,
String? identifier,
});

EventHandler<T> is FutureOr<void> Function(EventEnvelope<T>). SyncEventHandler<T> is the strictly-synchronous variant, void Function(EventEnvelope<T>). Use onSync when the handler will be invoked through emitSync and async would be a bug.

Default priority is Priority.normal (500). Higher runs first, matching the registry: in both subsystems, higher numbers mean more authority. A handler at Priority.elevated (1000) runs before one at Priority.normal (500).

The returned EventSubscription is a typed handle that supports only cancel(). It is intentionally NOT a StreamSubscription because the bus is not stream-backed and has no backpressure, data callbacks, or asFuture semantics. Distinct from EventBinding, which is the declarative descriptor consumed by the Flutter listener-lifecycle utilities.

void Function() bind(EventBindingCallback callback);

EventBindingCallback is void Function(EventEnvelope<dynamic>). The callback fires for every non-internal event, before typed handlers run. It cannot stop the cascade. Use it for tracing, analytics, or debug logging.

The return value is the unbind function. Call it to remove the callback.

void Function() demonstrateBind(PluginContext context) {
final unbind = context.bus.bind((envelope) {
print('event ${envelope.event.runtimeType}');
});
return unbind;
}

Internal events emitted via emitInternal skip bind callbacks entirely.

bool hasRequestHandler<Request, Response>({String? identifier});

Reports whether at least one handler is registered for the (Request, Response) type pair. With identifier, only identifier-scoped handlers count. Useful for capability probes (does anyone in this scope answer this question yet) without paying for an exception.

Future<EventEnvelope<T>> emit<T>({
required T event,
String? identifier,
bool internal = false,
}) async {

emit wraps event in an EventEnvelope<T>, runs bind callbacks (when not internal), then walks the priority-merged handler chain. The cascade stops when a handler calls envelope.stop(value). The returned envelope carries the final state; check stopped if you need to know whether someone short-circuited.

emitSync has the same dispatch semantics but throws StateError if any handler returns a Future. Use it for lifecycle events where async would be a correctness bug.

emitInternal is a thin wrapper that calls emit with internal: true. The only doc-visible difference: bind callbacks do not see internal events. Most application code will never call it; it is for system-level signals you want typed handlers to react to without cluttering analytics taps.

The internal parameter on emit itself is rarely used directly; prefer emitInternal so the intent reads at the call site.

The same bus also does typed RPC. The dispatch model mirrors emit: general handlers are merged with identifier-scoped handlers into one descending-priority sequence (higher first). Each handler is invoked in turn. The first handler to return a non-null Response wins; returning null concedes to the next handler.

RequestHandler<Request, Response> always returns Response?, so handlers can concede with null even when Response is non-nullable. The cascade behavior applies in both cases.

EventSubscription onRequest<Request, Response>(
RequestHandler<Request, Response> handler, {
int priority = Priority.normal,
String? identifier,
});
Future<Response> request<Request, Response>(
Request request, {
String? identifier,
});
Future<Response?> maybeRequest<Request, Response>(
Request request, {
String? identifier,
});

RequestHandler<Request, Response> is FutureOr<Response?> Function(EventEnvelope<Request>). The handler reads envelope.event to get the request payload, returns a non-null Response to claim, returns null to concede (so the next handler in priority order can try), or throws to signal a real error (the chain stops and the original exception propagates).

Pick the right method at the call site. maybeRequest is the canonical method when concession is a valid outcome: it returns null for the no-answer case. request is the assertion variant; use it only when at least one handler is guaranteed to claim.

When the chain produces no answer, request throws one of two sealed subtypes of NoRequestAnswerException:

  • RequestNotWiredException: no handler is registered for the type pair, or no handler matched the requested identifier (carries a wasIdentifierMismatch boolean to distinguish). Almost always a wiring bug.
  • AllConcededException: every registered handler ran and returned null on a non-nullable Response. The message recommends switching to maybeRequest.

maybeRequest catches NoRequestAnswerException and converts it to null. Any other exception thrown by a handler propagates unchanged through both methods; maybeRequest does not swallow handler exceptions.

EventSubscription onRequestSync<Request, Response>(
SyncRequestHandler<Request, Response> handler, {
int priority = Priority.normal,
String? identifier,
});
Response requestSync<Request, Response>(
Request request, {
String? identifier,
});
Response? maybeRequestSync<Request, Response>(
Request request, {
String? identifier,
});

SyncRequestHandler<Request, Response> is Response? Function(EventEnvelope<Request>). Same concession-by-null and throw-stops-chain rules as the async variant. Handlers registered via onRequestSync are stored in the same buckets as async ones; the sync wrapper exists to refuse Future returns at the call boundary.

requestSync throws StateError if any invoked handler returns a Future. Same no-answer throw conditions as request: RequestNotWiredException for missing handlers, AllConcededException for fully-conceding chains with non-nullable Response. maybeRequestSync catches NoRequestAnswerException and returns null on the no-answer case; any other exception thrown by a handler (including the StateError on a Future-returning sync handler) propagates, matching the async maybeRequest contract.

Both emit and request accept an optional String? identifier. So do on, onRequest, and the sync variants. The dispatch rules:

Emit identifierSubscriber identifierHandler runs?
nullnullyes
null'foo'no
'foo'nullyes
'foo''foo'yes
'foo''bar'no

When the emit identifier is non-null, general (null-identifier) handlers and handlers scoped to that exact identifier are merged into a single priority-ordered sequence. Handlers scoped to any other identifier never see the event.

The same table applies to request handlers.

Identifiers are how one bus multiplexes across logical targets (tools, agents, panels, departments) without forcing a separate bus per target. They have no implicit hierarchy: foo and foo.bar are unrelated strings as far as the bus is concerned.

Helpers on Plugin, StatefulPluginService, and PluginSession

Section titled “Helpers on Plugin, StatefulPluginService, and PluginSession”

The bus is reachable directly on every context, but the convenience extensions exported through src/plugin/plugin.dart exist so plugin code never has to manage subscription fields by hand.

extension PluginHelper on Plugin {
EventSubscription on<E>(
PluginContext context,
EventHandler<E> handler, {
int priority = Priority.normal,
String? identifier,
});
EventSubscription onRequest<Request, Response>(
PluginContext context,
RequestHandler<Request, Response> handler, {
int priority = Priority.normal,
String? identifier,
});
EventSubscription onRequestSync<Request, Response>(
PluginContext context,
SyncRequestHandler<Request, Response> handler, {
int priority = Priority.normal,
String? identifier,
});
void Function() bind(PluginContext context, EventBindingCallback callback);
Future<EventEnvelope<T>> emit<T>(
PluginContext context,
T event, {
String? identifier,
});
}

Every helper takes the PluginContext as its first argument because the same plugin instance is shared across sessions; there is no safe “current context” to read from a field. Inside Plugin.attach / Plugin.detach the context parameter is right there; pass it along.

Each subscription helper registers on context.bus and bookkeeps the returned EventSubscription into a per-context bucket. The framework cancels each context’s bucket when that context detaches, so concurrent sessions of the same plugin do not trample each other’s teardown.

StatefulPluginService exposes the same shape via the StatefulPluginServiceHelper extension. The service holds its own context reference (bound by the framework before attach() runs), so these helpers do NOT take a PluginContext parameter:

EventSubscription on<E>(EventHandler<E> handler, {int priority = Priority.normal, String? identifier});
EventSubscription onRequest<Request, Response>(
RequestHandler<Request, Response> handler, {
int priority = Priority.normal,
String? identifier,
});
EventSubscription onRequestSync<Request, Response>(
SyncRequestHandler<Request, Response> handler, {
int priority = Priority.normal,
String? identifier,
});
void Function() bind(EventBindingCallback callback);
Future<EventEnvelope<T>> emit<T>(T event, {String? identifier});

Subscriptions are appended to StatefulPluginService.activeSubscriptions and cancelled on detach. emit throws StateError if called outside the attach/detach window.

The full service reference, including attach/detach ordering and injectSettings, lives on the Service Registry & Capabilities page.

extension SessionHelper on PluginSession {
Future<EventEnvelope<T>> emit<T>(T event, {String? identifier});
Future<EventEnvelope<T>> emitInternal<T>(T event, {String? identifier});
EventSubscription on<T>(
EventHandler<T> handler, {
int priority = Priority.normal,
String? identifier,
});
EventSubscription onRequest<Request, Response>(
RequestHandler<Request, Response> handler, {
int priority = Priority.normal,
String? identifier,
});
EventSubscription onRequestSync<Request, Response>(
SyncRequestHandler<Request, Response> handler, {
int priority = Priority.normal,
String? identifier,
});
Future<Response> request<Request, Response>(Request request, {String? identifier});
Future<Response?> maybeRequest<Request, Response>(Request request, {String? identifier});
Response requestSync<Request, Response>(Request request, {String? identifier});
Response? maybeRequestSync<Request, Response>(Request request, {String? identifier});
}

The session helpers do not auto-track subscriptions: a PluginSession is the lifetime that owns the bus, so when the session ends the bus is disposed and every handler goes with it. Use these from host code that is driving a session from the outside.

The runtime keeps the global bus and each session bus isolated. Cross-scope emission is always explicit:

FromToAPI
Global pluginGlobal pluginscontext.bus.emit(...)
Session pluginSame-session pluginscontext.bus.emit(...)
Session pluginGlobal scopecontext.globalBus.emit(...)
Global pluginEvery active sessioncontext.sessions.emit<T>(event)

SessionPluginContext.globalBus is the same EventBus instance owned by the runtime. Reading it is fine; disposing it is not.

context.sessions.emit<T>(...) is provided by the SessionBroadcast extension on List<PluginSession>:

extension SessionBroadcast on List<PluginSession> {
Future<void> emit<T>(T event, {String? identifier});
}

Each session’s cascade starts concurrently (Future.wait with eagerError: false); every session runs to completion regardless of whether a peer threw, and the first error encountered surfaces on the returned future once all sessions have settled. If you need to inspect each session’s outcome independently, iterate the list and try/catch around session.bus.emit yourself.

The full cross-scope story, including why isolation defaults are intentional, is on Sessions.