plugin_kit 0.1.0 copy "plugin_kit: ^0.1.0" to clipboard
plugin_kit: ^0.1.0 copied to clipboard

A powerful plug'n'play plugin system for any Dart project. Lifecycle-aware plugins, scoped services, an event bus, sessions, and runtime configuration.

plugin_kit. A plugin runtime for Dart.

pub package License: BSD-3-Clause

A Dart plugin runtime for apps that have grown into platforms. Features get real lifecycles. Services get replaceable, prioritized implementations. Sessions stay sealed. Events flow between parts of your app that have never been formally introduced.

It has no opinion about what your app does. It does not know about Flutter, servers, agents, editors, or any particular settings backend. You build those on top. The runtime stays the same.

Three primitives carry the whole library:

  • Service registry: priority, override, disable, and hot-swap, keyed by typed ServiceId handles. Higher priority wins resolution; settings can override or disable per-slot.
  • Event bus: typed envelope cascade with mutation and stop, plus typed request/response. Handlers can mutate the payload, halt the cascade with a value, or chain through priorities.
  • Plugin lifecycle: registerattachdetach. Scope is GlobalPlugin (one instance per runtime) or SessionPlugin (one instance shared across sessions, with per-session service instances).

Plugins are wiring; services are the meat. The plugin class declares an id, registers services, and stays small. Real behavior, anything stateful or configurable or replaceable, lives in services.

Pure Dart. No Flutter. Depends only on collection, logging, and meta.

Current limitation: plugins are in-process only #

Every plugin compiles into your application binary. There is no runtime mechanism to load plugins from a separate process, a network endpoint, or a sandboxed worker. The runtime, the registry, the event bus, and the lifecycle hooks all assume in-process calls and share Dart object identity.

Out-of-process plugin loading (over IPC, WebSocket, or a wasm boundary) is planned for a future release but not yet implemented. It needs real work on serialization, lifecycle isolation, version negotiation, and a security model that this release does not attempt. If you need cross-process plugins today, this runtime is not yet that, but it is being built toward it.

When not to use this #

If your app has one HTTP client, one auth service, one analytics service, and a few screens that call them, use the boring thing. Instantiate the client. Register the service. Ship the app.

Plugin Kit earns its weight when behavior needs to be replaced, layered, disabled, overridden, or vetoed while the app is running, and settings have stopped being data your app reads and started being something that actively reshapes the system. The rest of this README assumes you are past that line.

Quick start #

Two plugins claim the same greeter slot at different priorities. The runtime resolves the higher-priority winner; the host code never sees the competition.

class CasualPlugin extends SessionPlugin {
  @override
  PluginId get pluginId => const PluginId('casual');

  @override
  void register(ScopedServiceRegistry registry) {
    registry.registerSingleton<Greeter>(
      const ServiceId('greeter'),
      () => CasualGreeter(),
    );
  }
}

class FormalPlugin extends SessionPlugin {
  @override
  PluginId get pluginId => const PluginId('formal');

  @override
  void register(ScopedServiceRegistry registry) {
    registry.registerSingleton<Greeter>(
      const ServiceId('greeter'),
      () => FormalGreeter(),
      priority: Priority.elevated, // wins (beats Priority.normal default)
    );
  }
}

Future<void> runGreeterExample() async {
  final runtime = PluginRuntime(plugins: [CasualPlugin(), FormalPlugin()])
    ..init();
  final session = await runtime.createSession();

  final greeter = session.resolve<Greeter>(const ServiceId('greeter'));
  print(greeter.greet('world')); // Good day, world.

  await runtime.dispose();
}

The formal plugin wins because it asked for higher priority. The casual plugin's greeter is still registered, sitting at the lower priority, ready to win the moment a settings update lowers the formal plugin or disables it. The call site never branches on the choice.

That move, features owning slots and slots resolving to the current winner, is the vocabulary the rest of the library is built on.

Less breakable runtime #

A class of bugs that are usually situationally avoidable, this runtime makes structurally impossible.

  • attach / detach are framework-enforced. Subscriptions opened in attach on a StatefulPluginService are tracked and cancelled after detach returns.
  • Lifecycle failures aggregate. Errors surface as a PluginLifecycleException with the named phase and every plugin's error, instead of one plugin throwing and aborting the rest.
  • Intent and reality are separate sets. enabledPlugins is settings-intent; attachedPlugins is what the runtime actually attached after the dependency cascade. A plugin whose dependency is missing does not silently appear to be running.
  • Reconciliation is transactional. Per-plugin attach failures roll back to honest state. updateSettings rolls every reconciled session and the global scope back to the previous snapshot on any mid-loop failure, so callers never see split-brain.

Plugins #

A plugin has a unique PluginId, optional dependencies, optional featureFlags, and three lifecycle hooks:

abstract class Plugin {
  PluginId get pluginId;
  Set<PluginId> get dependencies => const {};
  List<FeatureFlag> get featureFlags => const [];

  void register(ScopedServiceRegistry registry) {}
  void attach(covariant PluginContext context) {}
  Future<void> detach(covariant PluginContext context) async {}

  // Mid-session reactivity: invoked when RuntimeSettings change for a plugin
  // that stays enabled across the change. Override to reconnect, swap models,
  // invalidate caches, etc.
  Future<void> onPluginSettingsChanged(
    covariant PluginContext oldContext,
    covariant PluginContext newContext,
  ) async {}
}

Two scopes:

  • GlobalPlugin: registered once during PluginRuntime.init, shared across every session.
  • SessionPlugin: attached per session, but the same plugin instance is reused across sessions, so mutable plugin fields are shared unless state lives in services or context.

Both default-context generics are inferred. Write extends GlobalPlugin / extends SessionPlugin without type arguments unless you need a custom context subclass.

Services #

Behavior another plugin should override, settings-tune, or disable belongs in a service. Three base classes; pick by what the behavior actually needs:

Need Base class Registration
No settings, no events, no lifecycle. Plain Dart class. registerFactory / registerSingleton / registerLazySingleton
Settings injection from RuntimeSettings.services. PluginService. All three. Override onSettingsInjected() to react to changes (no super, no args; read config / settings directly).
Lifecycle, events, or session-bound state. StatefulPluginService (or aliases SessionStatefulPluginService / GlobalStatefulPluginService). registerSingleton / registerLazySingleton only; factories rejected. attach(), detach(), and onSettingsInjected() are pure user hooks (no super). Auto-tracked event helpers (on, onRequest, bind, emit) read this.context implicitly.
class ChatThread extends StatefulPluginService {
  /// The accumulated messages for this session.
  final List<Message> messages = [];

  @override
  void attach() {
    on<NewMessage>((e) => messages.add(Message(e.event.text)));
  }

  @override
  Future<void> detach() async {
    messages.clear();
  }
}

Service registry #

Priority-based, keyed by typed ServiceId handles. Inside a plugin's register, the registry is plugin-scoped, so registrations auto-fill pluginId.

// Factory: new instance each resolve.
registry.registerFactory<MyService>(
  const ServiceId('my_service'),
  () => MyServiceImpl(),
);

// Singleton: factory runs ONCE at registration; same instance for every resolve.
registry.registerSingleton<MyService>(
  const ServiceId('my_service'),
  () => MyServiceImpl(),
);

// Lazy singleton: factory runs once on first resolve, cached after.
registry.registerLazySingleton<MyService>(
  const ServiceId('my_service'),
  () => MyServiceImpl(),
);

// Resolve at point of use (so hot-swaps take effect).
final service = context.resolve<MyService>(const ServiceId('my_service'));

// Walk the chain when you want the next-best implementation.
final fallback = context.resolveAfter<MyService>(
  pluginId: const PluginId('primary'),
  serviceId: const ServiceId('my_service'),
);

Higher priority wins, in both the registry and the event bus. Default is Priority.normal (500). Reach for the named stops on Priority (elevated, high, ...) when you want a discoverable override level, or Priority.above(other) / Priority.below(other) for relative positioning. Raw ints work too.

Build dotted/namespaced ids with Namespace:

const agent = Namespace('agent');

registry.registerSingleton<Model>(agent('model'), () => GptModel());
registry.registerSingleton<Tools>(agent.child('mcp')('tools'), () => McpTools());

context.resolve<Model>(agent('model'));

The registry knows nothing about namespaces; they are pure composition into the ServiceId string.

Event bus #

Typed, priority-ordered. Handlers receive an EventEnvelope<T>, read or mutate the payload via e.event, and call e.stop(value) to halt the cascade with a final value. There is one subscription primitive (on<T>); a "read-only observer" is just a handler that doesn't mutate.

Future<void> demonstrateMutateAndStop(EventBus bus) async {
  bus.on<MyEvent>((env) async {
    env.event = env.event.copyWith(modified: true);
  }, priority: 10);

  bus.on<MyEvent>((env) async {
    if (env.event.shouldCancel) env.stop(MyEvent.cancelled);
  });

  final result = await bus.emit<MyEvent>(event: const MyEvent());

  bus.bind((obs) => print('saw ${obs.event}'));

  bus.onRequest<SearchQuery, SearchResults>(
    (req) async => const SearchResults(results: ['r']),
  );

  final results = await bus.request<SearchQuery, SearchResults>(
    const SearchQuery(query: 'dart patterns'),
  );

  print(results.results);
  print(result.event.shouldCancel);
}

Pick the right method at the call site. maybeRequest / maybeRequestSync are canonical: they return null when the chain produced no answer (no handler wired, no handler matched the identifier, or every handler conceded). request / requestSync are the assertion variants: use them only when at least one handler is guaranteed to claim; they throw if the chain bottoms out.

The throws are typed. request / requestSync raise one of two sealed subtypes of NoRequestAnswerException:

  • RequestNotWiredException: no handler is registered for the (Request, Response) type pair, or no handler matched the requested identifier (carries a wasIdentifierMismatch bool to distinguish). Almost always a wiring bug; fix by registering a handler.
  • AllConcededException: every registered handler ran and returned null on a non-nullable Response. The exception message recommends switching to maybeRequest; do that if concession is a valid outcome at your call site.

Handler-thrown exceptions are NOT wrapped. They propagate as-is through both request and maybeRequest. maybeRequest catches only NoRequestAnswerException and converts it to null. null from maybeRequest means "no one answered," not "a handler crashed."

Sessions #

A session is an isolated execution scope with its own registry, event bus, and context. Open as many as you want. Closing one tears down only its session-scoped plugins and services.

final session = await runtime.createSession();
// ... use session.bus, session.resolve(...) ...
await session.dispose();

enabledPlugins is settings-intent (what RuntimeSettings says is on); attachedPlugins is runtime-effective (what the runtime actually attached after dependency cascade). Use enabledPlugins for settings UI; attachedPlugins for "is it actually running."

Settings and reconciliation #

RuntimeSettings is JSON-serializable top-level configuration. Plugin entries are keyed by PluginId; service entries use Pin (an extension type wrapping the canonical 'pluginId:serviceId' wire string). Wildcard overrides apply to whichever plugin currently wins resolution for a given ServiceId.

/// Demonstrates constructing [RuntimeSettings] with [Pin] keys and
/// performing a JSON round-trip.
RuntimeSettings demonstrateSettingsWithPin() {
  final settings = RuntimeSettings(
    plugins: {const PluginId('formal'): const PluginConfig(enabled: false)},
    services: {
      Pin('chat', ['agent', 'model']): const ServiceSettings(
        config: {'temperature': 0.7},
      ),
      Pin.wildcard(['agent', 'tools']): const ServiceSettings(priority: 200),
    },
  );

  // JSON round-trip preserves the wire format ("chat:agent.model", "*:agent.tools").
  final json = settings.toJson();
  final back = RuntimeSettings.fromJson(json);
  return back;
}

Hand the runtime a new RuntimeSettings and reconciliation runs serialized:

  • Newly-enabled plugins go through register then attach.
  • Staying-enabled plugins receive onPluginSettingsChanged(oldContext, newContext).
  • Newly-disabled plugins go through detach and unregister.

Plugin instances persist across reconciliation. Singleton and lazy-singleton service instances are reused; factory services are recreated on resolve. The stored snapshot only advances after every reconcile succeeds.

Capabilities #

Discover what a service can do without instantiating it. Capability is an empty base class; subclass for whatever your app cares about, attach at registration time.

void registerWithCapabilities(ScopedServiceRegistry registry) {
  registry.registerFactory<MyService>(
    const ServiceId('importer'),
    () => const MyService(),
    capabilities: const {
      SupportsFileFormats({'jsx', 'dart'}),
    },
  );
}

SupportsFileFormats? resolveCapability(ServiceRegistry registry) {
  final wrapper = registry.resolveRaw<MyService>(const ServiceId('importer'));
  return wrapper.capabilities.getOfType<SupportsFileFormats>();
}

UiConfigurableCapability is a built-in capability that ships with this package (Dart-only declaration of editable fields); the Flutter UI for it lives in plugin_kit_dialog so non-Flutter consumers never pull in Flutter.

Companion packages #

State management libraries own presentation state. Plugin Kit owns participation. The two Flutter packages below add the widget plumbing for participation to flow through the tree; they do not replace your state library.

Package Adds
flutter_plugin_kit PluginRuntimeScope and PluginSessionScope scope StatefulWidgets that provide inherited runtime/session access, a State mixin that auto-cancels bus subscriptions across session swaps, a ChangeNotifier adapter, and BuildContext.watchEvent<E>() / readEvent<E>() extensions.
plugin_kit_dialog A drop-in three-tab Flutter UI for inspecting and editing any PluginRuntime: toggle plugins, edit configurable services, browse the registry.

Both are optional. The runtime works without them.

Public API #

The canonical surface lives in dartdoc on pub.dev. What follows is a curated index by concern.

import 'package:plugin_kit/plugin_kit.dart';

// Typed handles
PluginId, ServiceId, Namespace, PluginNamespaced, Pin

// Plugins
GlobalPlugin<G extends GlobalPluginContext>
SessionPlugin<S extends SessionPluginContext>
FeatureFlag                    // .experimental, .locked
PluginContext, GlobalPluginContext, SessionPluginContext

// Services
PluginService                  // settings injection
StatefulPluginService<PKC extends PluginContext>
SessionStatefulPluginService   // alias for StatefulPluginService
GlobalStatefulPluginService    // alias for StatefulPluginService<GlobalPluginContext>

// Runtime
PluginRuntime, PluginSession, PluginRuntime

// Service registry
ServiceRegistry, ScopedServiceRegistry
LocalPluginOverride            // per-resolve scope override

// Event bus
EventBus, EventEnvelope, EventBinding
NoRequestAnswerException        // sealed base
RequestNotWiredException        // subtype: no handler / no identifier match
AllConcededException            // subtype: every handler returned null

// Settings
RuntimeSettings, PluginConfig, ServiceSettings

// Config
ConfigNode

// Capabilities
Capability, CapabilitySet
UiConfigurableCapability       // Dart-only field declaration; UI in plugin_kit_dialog
ConfigField (sealed)           // Text, Multiline, Password, Number, Dropdown<T>, Bool, Group, Extension

Documentation #

  • Full docs: plugin-kit.saad-ardati.dev. Concepts, guides, tutorials, reference.
  • Source: github.com/SaadArdati/plugin_kit.
  • Examples under example/:
    • villain_lair/: numbered-bin tour through every primitive.
    • model_embassy/: competing providers, capabilities, and reconciliation.
    • state_garden/: same chat pattern bridged to seven Flutter state-management libraries (live demo).
    • code_editor/: full Flutter capstone (live demo).

License #

BSD 3-Clause. See LICENSE.

2
likes
160
points
207
downloads

Documentation

API reference

Publisher

verified publishersaad-ardati.dev

Weekly Downloads

A powerful plug'n'play plugin system for any Dart project. Lifecycle-aware plugins, scoped services, an event bus, sessions, and runtime configuration.

Homepage
Repository (GitHub)
View/report issues

Topics

#plugins #architecture #event-bus #dependency-injection #lifecycle

License

BSD-3-Clause (license)

Dependencies

collection, logging, meta

More

Packages that depend on plugin_kit