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 type { SearchPort } from "@beignet/core/search";
import {
type AnyProviderConfigSchema,
createProvider,
type ServiceProvider,
} from "@beignet/core/providers";
import { z } from "zod";
export interface SearchProviderConfig {
API_KEY: string;
REGION?: string;
}
const SearchProviderConfigSchema = z.object({
API_KEY: z.string(),
REGION: z.string().optional(),
}) satisfies z.ZodType<SearchProviderConfig>;
export interface SearchProviderPorts {
search: SearchPort;
}
export type SearchProvider = ServiceProvider<
unknown,
AnyProviderConfigSchema<SearchProviderConfig>,
Pick<SearchProviderPorts, keyof SearchProviderPorts>
>;
export function createSearchProvider(): SearchProvider {
return createProvider({
name: "search",
config: {
schema: SearchProviderConfigSchema,
envPrefix: "SEARCH_",
},
async setup({ config, ports }) {
if (!config) throw new Error("Missing search provider config");
const client = await connectSearch(config.API_KEY, config.REGION);
return {
ports: {
search: createSearchPort(client),
} satisfies SearchProviderPorts,
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: ... }.
overrides merges defined values over the env-derived input before
validation, keyed by schema field name. Provider factories use it to make
code-supplied options win over environment variables — the framework applies
the precedence, so factories declare overrides: { API_KEY: options.apiKey }
instead of hand-rolling schema defaults or setup-time fallbacks. undefined
values are ignored, so absent options fall back to env and schema defaults.
First-party Beignet providers declare their config schemas with Zod, and that
is a sanctioned convention rather than drift from the framework's Standard
Schema stance: the config.schema seam stays library-agnostic, but a provider
package already carries vendor dependencies, its config schema is internal to
the package, and Zod's ubiquity makes provider code easier to read and
contribute to. Use whichever Standard Schema library you prefer in your own
providers; nothing in the framework depends on the choice.
Reusable provider packages should give the factory an explicit, named provider
return type. Use AnyProviderConfigSchema<Config> to erase the private
validation library and schema structure while keeping the validated output and
contributed ports exact. Export a plain config interface for that output, and
check the private schema with satisfies z.ZodType<Config>. This keeps schema
library upgrades and validation refinements out of the package's compatibility
surface without weakening InferProviderPorts.
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:
createXProvider(...) for lifecycle provider factories and createXPort() or
a domain-specific factory for direct implementations. Provider factories may
use env-backed defaults when called without options; reusable packages should
not export a shared provider instance.
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.drizzle),
};
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 function createSearchProvider() {
return 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, payments, notifications, auth, audit,
policies, 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": ["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);beignet provider audit reads the same manifest as a report-only inventory.
Use it while developing provider packages or reviewing an app's infrastructure:
the human table shows metadata validity, registration status, required env,
required tables, and declared app ports without failing CI. Use
beignet provider audit --json when a script or coding agent needs active
variants, watchers, and the full provider facts in machine-readable form.
Provider objects can also carry runtime-inert metadata (packageName,
ports, env, requiredEnv, requiredTables, 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,
requiredTables, and registration with the same shapes as the top-level
fields. requiredTables is for tables that are required whenever that provider
or variant is used; doctor checks them in infra/db/schema/, drizzle/, and
app database setup files. Top-level requiredEnv, requiredTables, 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.
README checklist
Provider package READMEs are part of the public setup surface. Keep first-party and reusable provider READMEs in the same shape so a user can answer the same questions every time:
- install command, including
@beignet/coreand required vendor SDKs - required and optional environment variables, with local-development notes
- minimal
server/providers.tsregistration or route/server-boundary wiring - ports installed by the provider, plus any provider-specific escape hatches
- instrumentation and devtools events the provider records
- failure behavior: what throws, what is retried, and what returns a framework response when a route helper is involved
- local/test substitute, such as a memory port or fake verifier
- deployment notes and provider-specific footguns
Verifier packages that do not install lifecycle ports should say that
explicitly and show where the verifier is passed, usually
createWebhookRoute(...) or verifyWebhook(...).
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. |
| Depends on an external worker dependency | Expose or document a readiness check that can prove the queue, stream, scheduler, or worker backend is reachable after app boot. |
First-party examples: @beignet/provider-db-drizzle implements the durable
outbox port with claim leases, attempts, retry timing, and dead-letter state;
@beignet/provider-jobs-bullmq maps Beignet fixed/exponential retry policy to
BullMQ attempts/backoff, exposes queue health on its escape hatch, and keeps
worker execution behind an explicit entrypoint;
@beignet/provider-jobs-inngest maps Beignet job total attempts to Inngest retries
and rejects retry fields Inngest cannot honor;
@beignet/provider-event-bus-redis adapts Redis Pub/Sub for cross-process
best-effort event delivery while documenting that missed messages are not
replayed and failed handlers are not retried or dead-lettered;
@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 (createMemoryEventBus), a provider factory
(createMemoryEventBusProvider) that passes ports through to
createProviderInstrumentation, typed contributed ports, and colocated tests.