Skip to main content

Writing plugins

Plugins extend Basica's AppBuilder with reusable builder functions.

Should you write a plugin?

Usually, no.

Write a plugin when you're authoring a library or otherwise expect to hand other people a typed API to wire something into their app, e.g. builder.with(yourPlugin, (b) => b.addYourThing(…)). A plugin exists to package an integration for reuse across many apps.

If you just want to put something into your own app (a background worker, a database connection, an HTTP server, a healthcheck) don't write a plugin. Just implement and register it directly on the lifecycle (see Lifecycle for services and entrypoints, and Healthchecks)

Lifecycle plugin

A plugin is a class that receives the lifecycle builder and exposes methods that wire things into it. You use it through builder.with:

plugin.ts
import { AppRequiredDeps, LifecycleManagerBuilder } from "@basica/core";
import { RegistersService } from "@basica/core/utils";

class LifecyclePlugin<D extends AppRequiredDeps> {
constructor(private readonly lifecycle: LifecycleManagerBuilder<D>) {}

addThing<const K extends string>(name: K) {
this.lifecycle.addService(name, () => new Thing());
return this as this & RegistersService<K, Thing>;
}
}

export const lifecyclePlugin = <D extends AppRequiredDeps>(
lifecycle: LifecycleManagerBuilder<D>
) => new LifecyclePlugin(lifecycle);
index.ts
const app = AppBuilder.registerDependencies()
.configureLifecycle((b) => b.with(lifecyclePlugin, (b) => b.addThing("cache")))
.build();

typeof app.services.cache; // Thing

Typing what a plugin registers

Always type what your plugin registers. When a method registers a service, entrypoint, or healthcheck, advertise it, so it stays typed on the built app (app.services.x, app.entrypoints.x, app.healthchecks.x).

There's one marker per registration kind, all from @basica/core/utils:

  • RegistersService<K, V>
  • RegistersEntrypoint<K, V>
  • RegistersHealthcheck<K, V>

Return this intersected with the marker(s) for whatever the method registered, and the markers accumulate across chained calls:

addThing<const K extends string>(name: K) {
this.lifecycle.addService(name, () => new Thing());
return this as this & RegistersService<K, Thing>;
}

When registration depends on the input

If what a method registers depends on how it's called, give each input shape its own overload and let each one advertise exactly what that path registers. This is how the first-party plugins do it:

// if the configuration for `Thing` is passed we'll register both a new service and an entrypoint
addThing<const K extends string>(
name: K,
config: Config
): this & RegistersService<K, Thing> & RegistersEntrypoint<K, Thing>;

// if `Thing` is passed to us directly, only a new entrypoint will be registered
addThing<const K extends string>(
name: K,
thing: Thing
): this & RegistersEntrypoint<K, Thing>;

// implementation
addThing(name: string, configOrThing: Config | Thing) {
// ...build/register based on the input, then:
return this as this & RegistersEntrypoint<string, Thing>;
}

Overload signatures have no body, so you must spell their return type.

warning

This works because overloads key on the input, which the compiler sees. Registration can't be made dynamic: a method must advertise the same markers every time it's called with a given signature. A registration that only happens on a runtime condition (a config flag, an env var) can't be advertised as guaranteed: split it into overloads keyed on the input.