Beignet is experimental alpha software. The 0.0.x package line is for early
evaluation, and APIs may change between releases while the framework settles.
Inngest-backed jobs provider for Beignet applications.
The provider installs the app-facing ctx.ports.jobs dispatcher for durable
background work using Inngest, a durable execution
platform for background jobs, scheduled functions, and workflows. In Beignet,
this package adapts Inngest to the JobDispatcherPort; it is not the Beignet
domain event bus.
The provider also exposes ctx.ports.inngest as an escape hatch for raw
Inngest access.
createInngestJobsProvider(...) returns the stable InngestJobsProvider
type. InngestConfig describes its validated config; the Zod schema remains
internal.
bun add @beignet/provider-jobs-inngest @beignet/core inngest@^4.12.1
Inngest v4 and Node.js 20 or newer are required.
import { createNextServer, createNextServerLoader } from "@beignet/next";
import { definePorts } from "@beignet/core/ports";
import { createInngestJobsProvider } from "@beignet/provider-jobs-inngest";
import { inngest } from "@/infra/inngest";
import { routes } from "@/server/routes";
// Set environment variables:
// INNGEST_APP_NAME=my-app (optional, defaults to "beignet-app")
// INNGEST_DEV=1 (local dev server only)
// INNGEST_EVENT_KEY=your-event-key (production cloud dispatch)
// INNGEST_SIGNING_KEY=your-signing-key (production endpoint verification)
const initialPorts = definePorts({});
export const getServer = createNextServerLoader(() =>
createNextServer({
ports: initialPorts,
providers: [createInngestJobsProvider({ client: inngest })],
context: ({ ports }) => ({
ports,
}),
routes,
}),
);
Once the provider is registered, your ports include:
jobs: the canonical JobDispatcherPort for app code.inngest: the raw Inngest escape hatch for advanced usage.Define jobs with @beignet/core/jobs, then dispatch them through
ctx.ports.jobs:
import { createJobs, retry } from "@beignet/core/jobs";
import { z } from "zod";
const { defineJob } = createJobs<AppContext>();
export const SendInviteEmailJob = defineJob("mail.invite.send", {
payload: z.object({
inviteId: z.string(),
inviteeEmail: z.string().email(),
}),
retry: retry.exponential({
attempts: 3,
}),
timeout: "30s",
async handle({ payload, ctx }) {
await ctx.ports.mailer.send({
to: payload.inviteeEmail,
subject: "You were invited",
text: `Invite id: ${payload.inviteId}`,
});
},
});
async function inviteUser(ctx: AppContext, input: InviteUserInput) {
const invite = await ctx.ports.db.invites.create({
inviterId: ctx.actor.id,
inviteeEmail: input.email,
});
await ctx.ports.jobs.dispatch(SendInviteEmailJob, {
inviteId: invite.id,
inviteeEmail: input.email,
});
return invite;
}
Inngest dispatchers are ordinary Beignet job dispatchers, so apps can wrap
them with createUniqueJobDispatcher(...) from @beignet/core/jobs when a
job's unique declaration should suppress duplicate dispatches through
ctx.ports.locks:
import { createUniqueJobDispatcher } from "@beignet/core/jobs";
import type { LocksPort } from "@beignet/core/locks";
import {
createInngestJobDispatcher,
type InngestPort,
} from "@beignet/provider-jobs-inngest";
export function createJobsPort(args: {
inngest: InngestPort;
locks: LocksPort;
}) {
const inngestJobs = createInngestJobDispatcher(args.inngest.client);
return createUniqueJobDispatcher({
jobs: inngestJobs,
locks: args.locks,
});
}
The uniqueness lease prevents duplicate Inngest event sends for the configured TTL. Inngest still owns execution retries, so handlers must remain idempotent for provider retries and process failures.
The Beignet provider and the Inngest SDK use environment variables with the
INNGEST_ prefix:
| Variable | Required | Description | Default |
|---|---|---|---|
INNGEST_APP_NAME |
No | Stable application ID shown in Inngest | "beignet-app" |
INNGEST_DEV |
Local only | Set to 1 when using the local dev server |
- |
INNGEST_EVENT_KEY |
Production | Authenticates events sent to Inngest Cloud | - |
INNGEST_SIGNING_KEY |
Production | Verifies requests to the function endpoint | - |
INNGEST_SIGNING_KEY_FALLBACK |
No | Previous signing key during rotation | - |
Do not deploy with INNGEST_DEV=1. Run beignet preflight in the production
environment to require both cloud credentials and reject local-dev mode.
beignet doctor --strict checks that installed Inngest jobs providers are
registered in server/providers.ts, that server/inngest.ts covers generated
job registries, and that Next.js apps expose app/api/inngest/route.ts.
Prefer one app-owned client shared by dispatch and execution:
import { createInngestJobsProvider } from "@beignet/provider-jobs-inngest";
import { inngest } from "@/infra/inngest";
export const providers = [
createInngestJobsProvider({ client: inngest }),
];
Calling createInngestJobsProvider() with no options remains available for
env-backed dispatch-only setups. Passing appName and eventKey configures
that provider-owned client directly.
jobs: JobDispatcherPortThe jobs port is the recommended application API. It validates and parses the
job payload with the job definition before sending an Inngest event using the
job name.
When tracing is active, the event data uses Beignet's versioned job transport
envelope. createInngestJobFunction(...) accepts both that envelope and legacy
raw payloads, unwraps before validation and context resolution, and continues
the producer trace. Unknown or malformed trace metadata is ignored without
blocking execution. Raw ctx.ports.inngest.send(...) events are unchanged.
await ctx.ports.jobs.dispatch(SendInviteEmailJob, {
inviteId: invite.id,
inviteeEmail: input.email,
});
inngest: InngestPortThe inngest port is an escape hatch for direct Inngest usage.
send<TData>(args: { name: string; data: TData }): Promise<void>Send a raw event to Inngest. Prefer ctx.ports.jobs.dispatch(...) for
first-class Beignet jobs.
await ctx.ports.inngest.send({
name: "user.invited",
data: {
inviterId: ctx.actor.id,
inviteeEmail: input.email,
inviteId: createdInvite.id,
},
});
client: InngestAccess the underlying Inngest client for advanced Inngest operations.
// Define Inngest functions using the client directly
const myFunction = ctx.ports.inngest.client.createFunction(
{ id: "my-function", triggers: { event: "user.invited" } },
async ({ event, step }) => {
// Your function logic
},
);
When @beignet/devtools is registered before this provider, calls to
ctx.ports.jobs.dispatch(...) and ctx.ports.inngest.send(...) are recorded
automatically under the jobs watcher. Successful enqueues are recorded as
scheduled; failed enqueues are recorded as failed with schedule-phase error
details.
Pass a static or lazy instrumentation target to
createInngestJobFunction(...) to record worker execution as started,
completed, and failed events:
import { createServiceActor } from "@beignet/core/ports";
import { createInngestJobFunction } from "@beignet/provider-jobs-inngest";
import { SendInviteEmailJob } from "@/features/users/jobs";
import { inngest } from "@/infra/inngest";
import { getServer } from "@/server";
const sendInviteEmail = createInngestJobFunction({
client: inngest,
job: SendInviteEmailJob,
ctx: async () =>
(await getServer()).createServiceContext({
actor: createServiceActor("beignet-inngest"),
}),
instrumentation: async () => (await getServer()).ports,
});
When that target exposes ports.tracing, the function starts the job span
before evaluating the lazy ctx factory. Service contexts created by the
factory therefore inherit the real active job span. Register the app-owned
OpenTelemetry SDK in the Inngest function process before initializing the
server.
To get proper type inference, include both provider-contributed ports in your app ports type:
import { definePorts } from "@beignet/core/ports";
import type { JobDispatcherPort } from "@beignet/core/ports";
import type { InngestPort } from "@beignet/provider-jobs-inngest";
// Your base ports, if any
const basePorts = definePorts({});
type AppPorts = typeof basePorts & {
jobs: JobDispatcherPort;
inngest: InngestPort;
};
This provider does NOT automatically subscribe to domain events. That is intentional: events are facts that happened, while jobs are explicit work to do.
To wire a domain event to a durable job, register a listener in your application and dispatch the job from the listener:
// features/users/listeners.ts
import { createListeners } from "@beignet/core/events";
import { UserInvited } from "@/features/users/domain/events";
import { SendInviteEmailJob } from "@/features/users/jobs";
import type { AppContext } from "@/app-context";
const { defineListener } = createListeners<AppContext>();
export const sendInviteEmail = defineListener("mail.send-invite-email", {
event: UserInvited,
async handle({ payload, ctx }) {
await ctx.ports.jobs.dispatch(SendInviteEmailJob, {
inviteId: payload.inviteId,
inviteeEmail: payload.inviteeEmail,
});
},
});
Register that listener against your event bus during infrastructure startup.
In tests, use createInlineJobDispatcher(...) from @beignet/core/jobs; in
production, install createInngestJobsProvider({ client: inngest }) and expose
the same client through the function endpoint.
Use createInngestJobFunction(...) to turn one first-class Beignet job into an
Inngest function, or createInngestJobFunctions(...) for a central registry.
The helpers subscribe to job.name, validate incoming event data with
parseJobPayload, and then call job.handle(...).
If the job defines a retry policy, the helper maps Beignet's total
retry.attempts value to Inngest's function-level retries option. Inngest
supports retry values from 0 to 20, so Beignet accepts total attempt counts
from 1 to 21 for Inngest-backed jobs. A job without an explicit attempt
count is configured with retries: 0, overriding Inngest's provider default so
it remains a single-attempt Beignet job.
Inngest-backed jobs support Beignet's retry attempt count only. Custom Beignet
backoff fields, jitter, and retryIf classification are outbox-worker
semantics; createInngestJobFunction(...) fails fast if a job policy includes
retry behavior that the adapter cannot honor.
When a job declares timeout, createInngestJobFunction(...) maps it to
Inngest's timeouts.finish setting. Inngest function timeouts use whole-second
precision, so Beignet fails fast for millisecond-precision timeouts that cannot
be represented exactly.
createInngestJobFunction(...) also accepts hooks. Function hooks wrap
job-local hooks around every handler attempt, run inside the Beignet timeout,
and fail through Inngest's function error path like handler errors. When the
Inngest SDK reports attempt metadata, Beignet forwards it to hooks using the
same one-based attempt convention as other job runners.
The Beignet terms still apply: attempts is the maximum total attempts,
including the first try, while Inngest's retries setting is the number of
retries after the first try. Inngest owns backoff and terminal failure handling
for direct Inngest jobs. Use the Beignet outbox when the app needs Beignet-owned
backoff, retry classification, or dead-letter state.
Pass errorReporter to report the native Inngest onFailure lifecycle after
all provider retries are exhausted. Individual attempts remain visible through
instrumentation and are not reported as incidents. The reporter may be a port
or a lazy resolver when server startup contributes it:
const sendInviteEmail = createInngestJobFunction({
client: inngest,
job: SendInviteEmailJob,
ctx: () => createBackgroundContext(),
errorReporter: async () => (await getServer()).ports.errorReporter,
});
Terminal reports include only the job name and Inngest function/run/event identifiers, never the event payload.
This is the provider convention for external job systems: map the Beignet semantics the provider can actually honor, document the mapping, and fail fast for retry fields that would otherwise be silently ignored.
Keep registration separate from the route so beignet make job can maintain
one explicit execution registry:
// server/inngest.ts
import { createServiceActor } from "@beignet/core/ports";
import { createInngestJobFunctions } from "@beignet/provider-jobs-inngest";
import { inngest } from "@/infra/inngest";
import { userJobs } from "@/features/users/jobs";
import { getServer } from "@/server";
export const inngestJobs = [...userJobs] as const;
export const inngestFunctions = createInngestJobFunctions({
client: inngest,
jobs: inngestJobs,
ctx: async () => {
const server = await getServer();
return server.createServiceContext({
actor: createServiceActor("beignet-inngest"),
});
},
instrumentation: async () => (await getServer()).ports,
errorReporter: async () => (await getServer()).ports.errorReporter,
});
// app/api/inngest/route.ts (Next.js App Router)
import { serve } from "inngest/next";
import { inngest } from "@/infra/inngest";
import { inngestFunctions } from "@/server/inngest";
export const { GET, POST, PUT } = serve({
client: inngest,
functions: inngestFunctions,
});
The lazy resolvers avoid booting the Beignet server when Next.js imports the route during a production build. They resolve only inside an active function execution.
The Inngest jobs provider:
setup:
jobs portinngest escape hatch portThe provider will throw errors in these cases:
retryIfMake sure to handle these during application startup.
For local development, you can run the Inngest Dev Server:
npx inngest-cli@latest dev
This provides a local UI at http://localhost:8288 where you can:
Use createInlineJobDispatcher(...) or a recording dispatcher in use-case tests
when you only need to assert dispatch intent. Use createInngestJobFunction(...)
tests for adapter behavior that depends on Inngest event payload shape.
Expose Inngest functions through the runtime adapter your host expects, such as
inngest/next for a Next.js route. This package targets Inngest v4 and Node.js
20 or newer. Configure the endpoint URL in Inngest Cloud and deploy both
INNGEST_EVENT_KEY and INNGEST_SIGNING_KEY. Use the Beignet outbox instead
of direct Inngest dispatch when enqueueing must commit atomically with a
database transaction or when the app needs Beignet-owned dead-letter state.
In non-Next apps, create the app server through that runtime's normal lifecycle
and pass its service-context factory, ports, and error reporter to
createInngestJobFunctions(...). The CLI-generated server/inngest.ts exposes
createAppInngestFunctions(...) for this binding instead of assuming a
Next-specific getServer() loader.
MIT