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:
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);
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.
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.