Plugins extend the runtime without coupling it to any framework. They are for app-level cross-cutting concerns: lifecycle integration, observability, persistence, routing bridges, telemetry, and tooling. Business state and actions should stay in modules; ordinary dependencies should stay providers.
Implement any subset of these hooks:
interface Plugin {
name?: string;
providers?: readonly ProviderInput[];
setup?(app: App, context: PluginContext): void | Promise<void>;
onModuleCreated?(event: ModuleCreatedEvent, context: PluginContext): void;
onActionStart?(event: ActionEvent, context: PluginContext): void;
onActionEnd?(event: ActionEvent, context: PluginContext): void;
onPatch?(event: PatchEvent, context: PluginContext): void;
onStateChange?(event: StateChangeEvent, context: PluginContext): void;
onError?(error: unknown, context: ErrorContext, pluginContext: PluginContext): void;
dispose?(context: PluginContext): void | Promise<void>;
}
interface PluginContext {
readonly app: App;
readonly name: string;
readonly signal: AbortSignal;
emitError(error: unknown, phase?: string): void;
onDispose(disposer: () => void | Promise<void>): void;
watch<T>(
read: () => T,
listener: (value: T, previous: T) => void,
options?: WatchOptions<T>,
): () => void;
}Register plugins through createApp({ plugins }):
const app = createApp({
plugins: [createLoggerPlugin()],
providers: [Counter],
});| Hook | Fires when |
|---|---|
providers |
Before app providers are registered. Providers are for services/tokens only, not CoSystem modules. |
setup(app, context) |
During app init (before onInit); may be async — start() awaits it. |
onModuleCreated(event) |
After each module instance is created and bound. |
onActionStart(event) |
When an action begins. |
onActionEnd(event) |
When an action settles (includes error on failure). |
onPatch(event) |
On each store patch. A plugin with onPatch enables patches unless engine.patches is set. |
onStateChange(event) |
On every store change. |
onError(error, context) |
When a runtime phase or plugin observer hook throws (context.phase). |
dispose(context) |
During app.dispose(); may be async. Context disposers run after this hook. |
See Application Lifecycle for the
exact ordering of setup, onInit, and effects.
interface ModuleCreatedEvent {
name: string;
token: InjectionToken;
instance: unknown;
}
interface ActionEvent {
module: string;
method: string;
args: readonly unknown[];
startedAt: number;
endedAt?: number;
error?: unknown;
}
interface PatchEvent {
patches: readonly unknown[];
inversePatches: readonly unknown[];
}
interface StateChangeEvent {
state: unknown;
}
interface ErrorContext {
phase: string;
}Use PluginContext for resources owned by the plugin:
const plugin: Plugin = {
name: "metrics",
setup(app, context) {
context.watch(
() => app.state.version,
(version) => sendMetric("state.version", version),
);
const stop = startExternalSubscription();
context.onDispose(stop);
},
};context.watch() is app.watch() plus automatic cleanup. context.onDispose()
registers any other teardown callback. context.signal is aborted before context
disposers run, so long-running async work can stop early.
Observer hook errors do not interrupt app actions or state updates. They are
reported to onError with a phase like plugin:metrics.onActionEnd. Errors from
setup() still fail app init, and errors during dispose() are aggregated and
re-thrown after teardown has been attempted.
Logger — createLoggerPlugin
Logs module creation, action completion/failure, and runtime errors. Ships in
@cosystem/core.
import { createLoggerPlugin } from "@cosystem/core";
createApp({ plugins: [createLoggerPlugin()], providers: [Counter] });
// pass { logger } to route messages somewhere other than consoleStorage — @cosystem/storage
Hydrates state on startup, persists changes through localspace drivers, and
exposes a cross-framework storage service through StorageToken.
import { StorageToken, createLocalSpaceStoragePlugin } from "@cosystem/storage";
type CounterAppState = {
readonly counter: {
readonly count: number;
};
};
const storage = createLocalSpaceStoragePlugin<CounterAppState>({
key: "cosystem:app",
options: {
name: "my-app",
storeName: "state",
},
partialize: (state) => ({ counter: (state as CounterAppState).counter }),
});
const app = createApp({ plugins: [storage], providers: [Counter] });
await app.start(); // waits for hydration
await app.get(StorageToken).set("draft", { title: "Hello" });Router — @cosystem/router
Bridges a Router into the app lifecycle and exposes it via RouterToken.
import { RouterToken, createBrowserRouter, createRouterPlugin } from "@cosystem/router";
const router = createBrowserRouter();
const app = createApp({
plugins: [createRouterPlugin(router, { onChange: (loc) => console.log(loc.path) })],
});
app.get(RouterToken).navigate("/settings");Devtools — @cosystem/devtools
Records a timeline of setup, module, action, patch, state, and error events.
import { createDevtoolsPlugin } from "@cosystem/devtools";
const devtools = createDevtoolsPlugin();
const app = createApp({ plugins: [devtools], providers: [Counter] });
devtools.subscribe((event) => console.log(event.type));A plugin is just an object. Keep state in a closure and return the hooks you need:
import type { Plugin } from "@cosystem/core";
export function createTimingPlugin(): Plugin {
const durations: number[] = [];
return {
name: "timing",
onActionEnd(event) {
if (event.endedAt !== undefined) {
durations.push(event.endedAt - event.startedAt);
}
},
setup(app, context) {
context.onDispose(() => {
durations.length = 0;
});
},
};
}Tips:
- Give every plugin a
name(used in error context and tooling). - Use
setup(app, context)for work that needs the liveApp(subscribing, resolving services); return a promise if it is async sostart()waits. - Prefer
context.watch()andcontext.onDispose()for resources that must be cleaned up with the app. - Use
providersonly for service/token dependencies. Plugin providers cannot register CoSystem modules. App-level non-multiproviders replace plugin providers for the same token; app-levelmultiproviders append to pluginmultiproviders. - A plugin with
onPatchenables patches automatically unlessengine: { patches: false }is set. - For imperative controls beyond the
Plugininterface (like storage'sflush()), return an object that extendsPluginwith extra methods, as the storage and devtools plugins do.
- Application Lifecycle — when hooks run.
- State & Reactivity — patches and state-change events.
- Worker & Shared Runtime — the worker host uses a patch plugin internally.