Manual composition
Explicit service contracts and runtime ownership outside a discovery-enabled app.
Use this guide when building a standalone Node program or a provider package that needs a shared contract. In a configured application, start with Services: discovery and the generated provider already perform registration and lifecycle management.
Explicit contracts across packages
Use a shared contract when independent provider and consumer packages need an agreed type. Unlike the discovered examples in the Counter learning path, this standalone composition names its services through record keys because no file discovery runs:
import {
createRuntime,
defineContract,
definePlugin,
defineService,
getService,
} from '@bitakit/core';
interface Clock {
now(): Date;
}
const clockService = defineContract('clock').of<Clock>();
const clockPlugin = definePlugin({
id: 'example-clock',
services: {
// ID example-clock.system, also available under the contract name "clock".
system: defineService(() => ({ now: () => new Date() }), { provides: clockService }),
},
});
const runtime = createRuntime({ plugins: [clockPlugin] });
const stop = runtime.mount();
try {
console.log(getService(runtime.services, clockService).now());
console.log(runtime.services['example-clock'].system.now()); // the same instance
} finally {
await stop();
}defineContract identifies a contract; provides registers the implementation under it; getService returns that instance with its type. Share the contract rather than defining unrelated keys in each consumer. Core ships two contracts, authService (auth) and translationService (i18n).
Prefer a single import? import * as bitakit from '@bitakit/core' works too, and bundlers still drop what you do not use.
Name the service where you define it
Services and actions are records keyed by name, and the IDs become <plugin>.<key>, exactly as discovery derives them from file paths. exposeAs: 'clock' adds a flat alias in services for deliberate flattening; it does not change the ID or create a second instance. Arrays of definitions with explicit ids are also accepted.
The same shape composes everything a plugin folder holds:
definePlugin({
id: 'todos',
dependencies: ['shell'],
services: { list }, // mirrors services/list.ts → services.todos.list
actions: { add, clear },
events: todosEvents, // defineEvents({ ... })
config: { limits }, // mirrors configs/limits.ts → app.config.todos.limits
interceptors: [audit],
setup: ({ scope, services }) => () => {},
ui: { pages: [...], slots: [...], providers: [...] },
});When to own a runtime
The example creates and mounts a runtime because it runs without an application host. createRuntime(config, { actions, services }) creates the runtime's ActionList for you; pass { actions } to use one you already share with an ActionProvider, or { services } for host-owned services such as authentication under their contract names. mount() creates services and installs contributions and returns the stop function; runtime.ready resolves after every service onStart.
In a configured app, the generated BitaKitProvider owns that lifecycle. Do not repeat these imports or startup code when adding a normal service or action.
For a test of one plugin folder, use the Testing plugins guide.
For a complete standalone feature, see the manual To-do walkthrough.
Slot availability
const host = definePlugin({
id: 'host',
slots: { toolbar: defineSlot({ accepts: 'actions' }) },
});
const editor = definePlugin({
id: 'editor',
actions: { save: defineAction({ execute: () => saveDocument() }) },
setup: ({ scope }) => {
scope.slots.attach({ slot: 'host.toolbar', actionId: 'editor.save' }, { availability: 'wait' });
scope.slots.watch('host.toolbar', (snapshot) => console.log(snapshot.phase));
},
});wait accepts a known definition and follows readiness; the default require rejects an
unready owner. Unknown targets fail under both policies. Scope close removes attachments and
observers. runtime.slots reads current state; runtime.slot is a notification on the existing
event bus. Actions still execute through the one ActionList. Rendering belongs to the owner.
Declared ui.slots component contributions use the same path. PluginSlot reads live component
entries after runtime readiness; hiding the outlet does not remove the attachment. Contextual
payloads and Action placements use the same contracts; App Preferences projects Settings automatically.
See host updates and inspection for compatible live updates.
Plugin setup and shared access
dependencies orders plugins and blocks dependents when a provider fails. Missing or cyclic
dependencies fail startup with the involved IDs. setup runs after Action registration and
may return cleanup. Put awaited work in a service's onStart; setup itself is synchronous.
Setup receives a runtime-issued PluginScope as scope; app always means the application
facade. Its registrations, group attachments, slot contributions and observers are released with
that owner. For example, scope.actions.getGroup(id).attach(actionId) adds logical group
membership with owned cleanup.
Groups do not place UI; use slots for placement.
The runtime's app facade exposes typed services, actions, configuration snapshots and
events. Check app.has(name) before optional service access. A missing direct read throws;
the availability check is not an authorization decision. Shared authentication uses the
AuthService contract.
React host composition
BitaKitRuntimeProvider from @bitakit/ui owns the runtime when composing React manually.
Generated applications already use it through BitaKitProvider. Keep one root provider.
config supplies the composition. additionalPlugins replaces entries with the same ID in
place and appends new IDs. The optional actions, plugins, interceptors and providers
props follow the same rule: omitted inherits configuration, an explicit array replaces that
domain, and an empty array selects none. An explicit actions array also suppresses plugin
Actions while retaining those plugins' services and setup. Keep composition values stable
across renders; a changed provider configuration replaces the runtime after cleanup.
Use services for host-owned contract implementations, translate and labels for language,
notify for feedback, and controls, dialogs and ui for existing host rendering.
actionOptions configures Action infrastructure; isolateFailures chooses startup isolation.
See Service lifecycle for failure handling.
Plugin UI providers wrap the app outside-in. export const requires = ['other.provider']
names a provider that must appear earlier; duplicate IDs and missing or reversed requirements
fail before mounting. Provider ordering is distinct from service dependencies.
Integration contribution kinds
An optional integration can own a declarative kind without adding its meaning to Core.
The contributing plugin supplies contributions: { [kind]: values }; exactly one owner
supplies installs: { [kind]: (values, { module, app, services }) => cleanup }.
An unclaimed or multiply claimed kind fails mount. Cleanup belongs to the contributing owner.
Installers run after services and interceptors, before Actions and setup, in dependency order.
The integration augments ContributionMap for its types.
runtime.contributionsOf(kind) reads the composed values before mount for an initial render.
scopeModule(scope) identifies a runtime-issued scope's owner; scopePlugins(scope) and
scopeModules(scope) inspect composition and register that scope as a composition reader.
Such readers join affected update sets; see Plugin manager.
runtime.destinationOf(action) reports whether navigation targets a page, settings or a
plugin. An integration can mark a projected destination with
destinationMetadata({ kind, plugin }). Discovery reads custom kinds from bitakit.config.ts
or a package's bitakit.discovery entry, using contributions/<folder>/ in each module.