BitaKit
Concepts

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.

On this page