Writing a provider
This page is for provider authors. A port is the app-facing interface, a
provider adapts an external system to Beignet at startup, and an adapter in
infra/ wires them into app ports — see Ports and adapters and
Providers for the app-side view.
A reusable provider package combines four things: a createProvider(...)
definition with a bounded lifecycle, typed contributed ports, provider
instrumentation, and a static metadata manifest in package.json.
Lifecycle
Define providers with createProvider(...) from @beignet/core/providers.
setup runs during server creation and returns the contributed ports plus
optional hooks: start runs after all providers have contributed ports, and
stop runs when the server is stopped.
import { createProvider } from "@beignet/core/providers";
import { z } from "zod";
export const searchProvider = createProvider({
name: "search",
config: {
schema: z.object({ API_KEY: z.string(), REGION: z.string().optional() }),
envPrefix: "SEARCH_",
},
async setup({ config, ports }) {
const client = await connectSearch(config.API_KEY, config.REGION);
return {
ports: { search: createSearchPort(client) },
async stop() {
await client.close();
},
};
},
});config accepts any Standard Schema library. envPrefix reads matching
environment variables and strips the prefix before validation, so
SEARCH_API_KEY becomes { API_KEY: ... }.
Lifecycle hooks should do bounded resource work: create clients, run startup
checks, close resources. Do not start polling loops, queue consumers, or other
unbounded background work from setup or start in serverless apps; put
background work behind explicit entrypoints such as cron routes, job
functions, or worker processes.
Inside setup, ports contains base app ports plus ports contributed by
earlier providers, and createServiceContext is a late-bound factory for app
service contexts. Calling it before all providers have started throws, so only
invoke it lazily from runtime entrypoints such as job dispatch or listeners.
Contributing ports and typing
Name exports after the conventions on Providers: xProvider for
ready-to-install singletons, createXProvider(...) for factories that take
options, createXPort() or a domain factory for direct implementations.
App-local providers should use the curried
createProvider<Requires, Context, ServiceInput>() form to declare the ports
they require from earlier providers plus their app context and service-context
input. Inside setup, ports is typed as the declared requirements and
createServiceContext returns the app context.
Annotate the returned ports with a Pick<AppPorts, ...> of the keys the
provider fulfills:
const providedPorts: Pick<AppPorts, "posts" | "uow"> = {
...repositories,
uow: createUnitOfWork(ports.db.db),
};
return { ports: providedPorts };That keeps AppRuntimePorts aligned with the app port contracts instead of
intersecting concrete adapter types. Two related inference details:
- Lifecycle hooks returned from
setupshould close over setup locals. Astart(ctx)hook with an unannotated parameter keeps TypeScript from inferring the provided ports from the returnedportsobject; annotatectxwithProviderLifecycleContext<...>if the hook needs typed ports. - Apps merge contributed ports with
InferProviderPorts<typeof providers>, so theProvidedtype your setup result infers is part of your public API.
When the port your provider installs is a stable Beignet port such as
CachePort or MailerPort, also expose the raw client under a
provider-specific key as an escape hatch, for
example redis or resend.
Instrumentation
Add provider instrumentation when a provider performs meaningful external work
that should appear in devtools. Use
createProviderInstrumentation() from @beignet/core/providers instead of
depending on devtools directly:
import {
createProvider,
createProviderInstrumentation,
} from "@beignet/core/providers";
export const searchProvider = createProvider({
name: "search",
setup({ ports }) {
const instrumentation = createProviderInstrumentation(ports, {
providerName: "search",
watcher: "custom",
});
return {
ports: {
search: {
async query(text: string) {
const results = await runSearch(text);
instrumentation.custom({
name: "search.query",
label: "Search query",
summary: `${results.length} results`,
details: { resultCount: results.length },
});
return results;
},
},
},
};
},
});The helper accepts a ports object or an instrumentation port and resolves the
sink in one canonical order: ports.instrumentation, then ports.devtools.
With no sink installed, recording is a no-op. The helper also checks watcher
enablement, applies Beignet's default redaction to event details, attaches
providerName, and swallows sink failures so instrumentation can never break
provider work.
Use the watcher that matches the provider's category — db, cache,
storage, uploads, mail, notifications, auth, audit, rateLimit,
jobs, outbox, schedules, or eventBus — and custom or a custom
watcher name for application-specific integrations.
Package metadata manifest
Reusable provider packages declare static metadata in package.json under
beignet.provider. It is side-effect-free and lets Beignet tooling inspect
installed provider packages without importing provider code, peer
dependencies, or environment-sensitive modules.
{
"name": "@acme/beignet-provider-search",
"beignet": {
"provider": {
"displayName": "Search provider",
"ports": ["search"],
"appPorts": [{ "name": "search", "type": "SearchPort" }],
"env": ["SEARCH_API_KEY", "SEARCH_REGION"],
"requiredEnv": ["SEARCH_API_KEY"],
"registration": {
"required": true,
"tokens": ["searchProvider", "createSearchProvider"]
},
"watchers": ["custom"]
}
}
}env lists all environment variables the provider may read; requiredEnv is
the subset that beignet doctor --strict should require in app config.
registration.required: true marks providers that apps must register in
server/providers.ts; doctor reports a missing registration as a warning,
which fails beignet doctor --strict. Optional-by-design providers such as
@beignet/devtools declare registration.severity: "hint" instead, so an
installed-but-unregistered package is reported as an informational hint that
never fails doctor. tokens lists the export names doctor looks for in
server/providers.ts.
beignet doctor --strict reads this metadata to check the generated app
convention: installed lifecycle provider packages registered, app-facing
provider ports declared in ports/index.ts, required env vars present in app
config. Malformed metadata is reported before provider-derived diagnostics are
used. Validate the manifest shape with parseProviderPackageMetadata:
import { parseProviderPackageMetadata } from "@beignet/core/providers";
const result = parseProviderPackageMetadata(packageJson.beignet?.provider);Provider objects can also carry runtime-inert metadata (packageName,
ports, requires, env, watchers) for app-local tooling and custom
diagnostics.
Variants
Packages whose subpath exports target different backends declare per-backend
metadata under variants instead of one top-level requirement set. The
first-party example is the Drizzle database provider, where each subpath reads
different env vars and registers a different factory:
{
"beignet": {
"provider": {
"displayName": "Drizzle database provider",
"ports": ["db"],
"env": [
"SQLITE_DB_URL",
"SQLITE_DB_AUTH_TOKEN",
"POSTGRES_DB_URL",
"MYSQL_DB_URL"
],
"watchers": ["db"],
"variants": [
{
"name": "sqlite",
"displayName": "Drizzle SQLite provider",
"env": ["SQLITE_DB_URL", "SQLITE_DB_AUTH_TOKEN"],
"requiredEnv": ["SQLITE_DB_URL"],
"registration": {
"required": true,
"tokens": ["drizzleSqliteProvider", "createDrizzleSqliteProvider"]
}
},
{
"name": "postgres",
"displayName": "Drizzle Postgres provider",
"env": ["POSTGRES_DB_URL"],
"requiredEnv": ["POSTGRES_DB_URL"],
"registration": {
"required": true,
"tokens": [
"drizzlePostgresProvider",
"createDrizzlePostgresProvider"
]
}
},
{
"name": "mysql",
"displayName": "Drizzle MySQL provider",
"env": ["MYSQL_DB_URL"],
"requiredEnv": ["MYSQL_DB_URL"],
"registration": {
"required": true,
"tokens": ["drizzleMysqlProvider", "createDrizzleMysqlProvider"]
}
}
]
}
}
}Each variant accepts name, optional displayName, env, requiredEnv, and
registration with the same shapes as the top-level fields. Top-level
requiredEnv and registration must be absent when variants is present;
declare them on each variant instead, and parseProviderPackageMetadata
rejects manifests that mix the two.
Doctor checks variant packages per detected variant: it matches each variant's
registration.tokens against server/providers.ts, requires the
requiredEnv of only the variants the app actually registers, and — when no
variant is detected — reports a single registration diagnostic that lists
every variant so the app can pick one.
Durable workflow conventions
Providers that participate in jobs, events, schedules, or outbox delivery must be explicit about the failure semantics they own. Do not silently downgrade a Beignet retry policy.
| Provider behavior | Requirement |
|---|---|
| Implements Beignet retry and dead-letter behavior | Store attempts, compute backoff or accept Beignet's computed retry time, and expose terminal failure state. |
| Maps to an external provider retry model | Document the mapping, preserve Beignet's total-attempt language, and fail fast when the external provider cannot honor backoff, jitter, or retry classification. |
| Runs work inline or in memory | Document that delivery is not durable and that process crashes can lose work. |
| Starts background work | Put workers behind explicit entrypoints such as cron routes, job functions, or worker processes. Do not start unbounded loops from serverless provider lifecycle hooks. |
First-party examples: @beignet/provider-db-drizzle implements the durable
outbox port with claim leases, attempts, retry timing, and dead-letter state;
@beignet/provider-inngest maps Beignet job total attempts to Inngest retries
and rejects retry fields Inngest cannot honor;
@beignet/provider-event-bus-memory is deterministic for tests but documents
that it is not a durable delivery provider.
Testing expectations
Provider packages ship colocated tests covering both the port behavior and the provider adaptation: the direct factory against the port contract, the provider's config loading and lifecycle, and instrumentation events when the provider records them. Keep app-specific conventions out of the package; ship strong defaults and a README with setup docs instead.
@beignet/provider-event-bus-memory is a compact reference implementation: a
direct port factory (createInMemoryEventBus), a provider factory
(createInMemoryEventBusProvider) that passes ports through to
createProviderInstrumentation, typed contributed ports, and colocated tests.