BitaKit
API reference

Service reference

Dependencies, startup hooks, cleanup and service contracts.

Start with one service and one store. Use this reference when the service needs another plugin or owns an external resource.

Factory context and store

defineService(factory, options?) calls the factory with one context object: { services, config, settings, events, store, onStart, onStop }. The returned object is the service instance. store(initial) supplies get, set, subscribe and optimistic. set accepts a value or updater; optimistic(apply, commit, { reconcile, revert }) applies a tentative change and reconciles or rolls it back after the commit. These operations do not add persistence.

The runtime observes exposed stores and subscribable instances, then refreshes reactive Action fields and UI bindings. Do not notify manually. Wrap external clients whose subscribe property is not an observable API rather than exposing them as the service itself.

Use a service from another plugin

Any contribution can use any service. List one that belongs to another plugin in requires when you want startup to report a missing plugin instead of the first click:

// src/plugins/reports/actions/summary.ts
import { defineAction } from '@bitakit/core';

export default defineAction({
  title: 'Summary',
  requires: ['billing.invoices'],
  execute: ({ services }) => services.billing.invoices.total(),
});

billing.invoices is the ID of the invoices service in the billing plugin. A service lists the services its factory reads in the second argument of defineService, even within the same plugin: that sets the creation order and lets BitaKit report cycles:

export default defineService(({ services }) => ({ total: () => services.billing.invoices.sum() }), {
  requires: ['billing.invoices'],
});

A service that is not listed still fails with a named error when it is first read. Startup suggests close names only for services listed in requires. A service that reads another one while it is being created must list it there, because requires also orders creation.

Lifecycle and resources

Core creates one instance per runtime, in dependency order, makes it available to every contribution and stops it in reverse order when the runtime stops. Put async startup work in onStart and cleanup in onStop:

export default defineService(({ store, onStart, onStop }) => {
  const state = store<{ todos: Todo[] }>({ todos: [] });
  onStart(async ({ signal }) => {
    const response = await fetch('/api/todos', { signal });
    state.set({ todos: await response.json() });
  });
  onStop(() => state.set({ todos: [] }));
  return { state };
});

The app is ready after every onStart has completed. A factory or start hook that throws is a startup error that names the plugin and the file; in development the plugin is turned off and the rest of the app keeps running.

Stop aborts startup and owned Actions, waits for their completion, then awaits all cleanup in reverse order. Required services stay available to dependent cleanup. Repeated calls share one completion; failures aggregate after the remaining disposers run. Always await stop before creating a replacement. Hooks must not await their own runtime's stop.

A headless host can select createRuntimeController(config, { failures: 'isolate' }); fail-fast is the default. Isolation rebuilds the whole remaining composition after cleanup, rather than preserving individual service instances. React uses this same policy and waits for previous cleanup when configuration changes.

Read runtime.lifecycle.get() or subscribe with runtime.lifecycle.watch(listener) for current plugin/service readiness and later updates. The event bus emits runtime.lifecycle for notifications; plugins cannot emit that runtime-owned event. Startup remains serial. Optional service consumers must check app.has(name) before reading. Missing direct reads (including auth and i18n) throw; ?. alone does not check registration.

Your service still owns its behavior and data. store() is in-memory state: BitaKit does not persist it, and store.optimistic(apply, commit) helps when the commit may fail. Use events when another plugin needs to react to a change.

Own startup and cleanup

Own startup and cleanup. Startup follows required dependencies. Teardown stops dependents first and awaits cleanup before reuse. A rejected disposer blocks replacement; repeated stop calls share completion.
Startup follows required dependencies. Teardown stops dependents first and awaits cleanup before reuse. A rejected disposer blocks replacement; repeated stop calls share completion.

Names, contracts and aliases

Services are named <plugin>.<file> and nested accordingly: services/billing/invoices.ts in plugin finance is services.finance.billing.invoices. Two options change that:

  • provides: contract registers the instance under a shared contract key as well, such as auth or i18n. Contracts are created with defineContract('auth').of<AuthService>(): the name is written once and stays literal, so provides also types services.auth in generated hosts. Share the contract between the package that provides a service and the plugins that use it.
  • exposeAs: 'clock' adds a flat alias in services. Use it only for deliberate flattening; two services with the same name fail at startup. A service name cannot also be a namespace: use better-auth-client, not auth.client, while auth is a service.

id is available for manual composition; discovery normally derives it. Missing access throws BitaKitServiceError, with service and a reason such as not-ready, unregistered or missing.

export default defineService(() => createAuthClient(), { provides: authService });

Most application features only need the files above. For packages or standalone programs without discovery, see Manual composition.

On this page