npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@beignet/core

v0.0.56

Published

Core framework primitives for Beignet

Readme

@beignet/core

Runtime: Beignet requires Node.js 22.12 or newer. Bun is optional.

Core framework primitives for Beignet

[!CAUTION] 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.

This package provides Beignet's framework primitives: contracts, server runtime, typed client, use cases, agent capabilities, ports, domain helpers, app errors, config, events, idempotency, locks, outbox, mail, notifications, payments, search, webhooks, feature flags, error reporting, encryption, broadcasting, schedules, uploads, entitlements, pagination helpers, testing helpers, and OpenAPI generation.

Small features can keep named workflows in features/<feature>/use-cases.ts. Use use-cases/ with an index.ts when workflows need separate modules; retain the same exports so route and test imports stay unchanged. See use-case organization.

Installation

npm install @beignet/core

# Use with your preferred Standard Schema library
npm install zod
# or
npm install valibot
# or
npm install arktype

TypeScript requirements

This package requires TypeScript 5.0 or higher for proper type inference.

Agent skills

This package ships a TanStack Intent skill for coding agents: @beignet/core#app-architecture. Load it when adding or fixing Beignet schemas, contracts, use cases, app errors, ports, policies, app context, providers, domain events, workflow primitives, seeds, tests, or core subpath imports.

Subpaths

Install @beignet/core once, then import the framework area you need. The package intentionally has no root entrypoint; use explicit subpaths so imports name the framework area they depend on.

| Import path | Responsibility | | --- | --- | | @beignet/core/agent-capabilities | Typed agent capability definitions, registries, validation, and execution | | @beignet/core/application | Use case builder and test helpers | | @beignet/core/broadcasting | Browser-safe typed channel definitions and transport validation | | @beignet/core/broadcasting/server | BroadcastPort, authorization bindings, registry, and authenticated origins | | @beignet/core/broadcasting/client | Multiplexed streaming browser client with reconnect reconciliation | | @beignet/core/client | Typed HTTP client | | @beignet/core/client-only | Static lint marker for modules intended for client-side imports | | @beignet/core/config | Environment config validation | | @beignet/core/contracts | HTTP contract builders, types, path helpers, and contract metadata | | @beignet/core/domain | Entities, value objects, and domain events | | @beignet/core/encryption | Authenticated string encryption, EncryptionPort, and key generation | | @beignet/core/entitlements | Product access decision types, helpers, and static entitlement adapter | | @beignet/core/error-reporting | Error reporting port, memory adapter, no-op adapter, and helpers | | @beignet/core/errors | Error catalogs and response helpers | | @beignet/core/errors/http | Framework HTTP error constants and status helpers | | @beignet/core/events | Events and listeners | | @beignet/core/flags | Feature flag definitions, FlagsPort, memory/static adapters, and helpers | | @beignet/core/idempotency | Retry-safe command, webhook, and job primitives | | @beignet/core/jobs | Job definitions, retry policies, timeout guards, execution hooks, execution lease helpers, uniqueness guards, and inline job dispatch | | @beignet/core/locks | Lease-backed LocksPort, memory adapter, memory provider, and helpers | | @beignet/core/mail | Mail port, memory mailer, and memory mailer provider | | @beignet/core/memo | Request-scoped memoization for port lookups | | @beignet/core/notifications | Notification definitions, dispatchers, inline notifications provider, mail channels, and test adapters | | @beignet/core/openapi | OpenAPI generation | | @beignet/core/outbox | Durable event and job outbox | | @beignet/core/payments | Payments port, memory payments adapter, and memory payments provider | | @beignet/core/pagination | Offset/cursor page types, normalizers, and result helpers | | @beignet/core/ports | App-facing ports, auth, audit, policies, cache, storage, best-effort work, logging, and redaction | | @beignet/core/providers | Provider lifecycle and instrumentation primitives | | @beignet/core/search | Search index definitions, SearchPort, memory adapter, memory provider, and helpers | | @beignet/core/schedules | Schedule primitives | | @beignet/core/server | Framework-agnostic server runtime, SSE responses, security headers, CSRF, and hook helpers | | @beignet/core/server-only | Static lint marker for modules that must stay out of client bundles | | @beignet/core/tasks | Operational task definitions and inline task execution | | @beignet/core/tenancy | Branded tenant scope helpers for repository boundaries | | @beignet/core/testing | Port and policy assertions, recording adapters, test context factories, memory port fixtures, provider install helper, factories, seeds, and database harnesses | | @beignet/core/tracing | Dependency-free W3C trace context primitives | | @beignet/core/uploads | Upload definitions (createUploads<AppContext>() app-bound builder), router, signer port, and test signer | | @beignet/core/uploads/client | Browser upload client for server and direct uploads | | @beignet/core/webhooks | Inbound webhook definitions, verifiers, memory test verifier, and HMAC verifier |

Encryption

Use createEncryption(...) in server infra and expose EncryptionPort through your app ports. It uses Web Crypto AES-256-GCM with a random 96-bit IV and a 128-bit authentication tag for each write.

import { createEncryption } from "@beignet/core/encryption";
import { env } from "@/lib/env";

// Supply a persistent server secret from your validated environment config.
const encryption = createEncryption({
  key: env.ENCRYPTION_KEY,
  previousKeys: env.ENCRYPTION_PREVIOUS_KEYS,
});
const context = { purpose: "integration-token", tenantId: "tenant-1" };
const encrypted = await encryption.encrypt({ value: "example-token", context });
const token = await encryption.decrypt({ value: encrypted, context });

Generate a persistent key with beignet encryption key or generateEncryptionKey(): canonical base64: plus 32 random bytes. All keys are validated synchronously. key encrypts new writes; previousKeys is an optional array used only for decryption. The methods accept strings and return Promise<string>. Optional context is a plain string record authenticated with the value; supply the same expected context when decrypting. It does not replace authorization.

Store the complete opaque ciphertext in a text column. Malformed values, tampering, wrong keys, and context mismatches reject with EncryptionDecryptionError and a non-sensitive message. There is no plaintext fallback. The API emits no logs or telemetry.

Keep keys outside client bundles and logs. Rotation does not rewrite existing data. Distribute a new decryption key to all readers before switching writes, retain old keys while records and backups require them, and test recovery. Losing a required key makes its ciphertext unreadable. This is field encryption, not password hashing or streaming file encryption. See the encryption guide for port wiring, rotation, recovery, and KMS boundaries.

Import boundaries

Use boundary markers as side-effect imports so local linting and formatting do not treat them as unused symbols:

import "@beignet/core/client-only";
import "@beignet/core/server-only";

Agent capabilities

Agent capabilities are validated application entrypoints for authenticated AI agent transports. Definitions remain transport-neutral and should delegate business behavior to the same use cases called by HTTP routes, jobs, and scripts.

import {
  createAgentCapabilities,
  createAgentCapabilityExecutor,
} from "@beignet/core/agent-capabilities";
import { z } from "zod";
import type { AppContext } from "@/app-context";
import { createIssueUseCase } from "@/features/issues/use-cases";

type AgentPrincipal = { agentId: string; userId: string };

const { defineAgentCapability, defineAgentCapabilityRegistry } =
  createAgentCapabilities<AppContext, AgentPrincipal>();

const createIssue = defineAgentCapability("issues.create", {
  description: "Create an issue in one workspace.",
  input: z.object({ workspaceId: z.string(), title: z.string().min(1) }),
  output: z.object({ id: z.string(), title: z.string() }),
  async handle({ ctx, input }) {
    const { workspaceId: _workspaceId, ...useCaseInput } = input;
    return createIssueUseCase.run({ ctx, input: useCaseInput });
  },
});

const registry = defineAgentCapabilityRegistry([createIssue]);

export const executor = createAgentCapabilityExecutor({
  registry,
  async createContext({ principal, input }) {
    const server = await import("@/server").then(({ getServer }) => getServer());
    const membership = await server.ports.members.findMembership({
      workspaceId: input.workspaceId,
      userId: principal.userId,
    });
    if (!membership) throw new Error("Not a workspace member");

    return server.createServiceContext({
      asUser: { id: principal.userId, role: membership.role },
      tenantId: input.workspaceId,
    });
  },
});

Input is validated before createContext(...) runs. Output is validated before it reaches the transport. Context construction remains app-owned: authenticate the transport first, re-read tenant membership from an authoritative port, and call server.createServiceContext(...) instead of assembling AppContext by hand. Use @beignet/agent-auth-better-auth to expose a registry through Better Auth Agent Auth.

Registry creation comes from the same app-bound factory as defineAgentCapability, so definitions with another context or principal type are rejected. Executor hooks observe the complete attempt and include a stage. Completion events contain the capability, context, principal, validated input, validated output, and duration. Failure events expose context and validated input only when execution reached those stages; raw malformed input and unvalidated output are not exposed. Dynamic transport adapters may provide authorize(...) to inspect the exact parsed input before context construction without causing a second validation pass. Pass an independent instrumentation target and tracing port to the executor when lookup, input-validation, and context failures must be visible before an app context exists. Without those options, successful context construction lets the executor derive observability ports from ctx.

Durable failure language

Jobs, outbox delivery, and schedule runners use the same terms:

  • attempt is the one-based execution or delivery attempt currently being handled.
  • attempts in a retry policy is the maximum total attempts, including the first try.
  • backoff is the delay before the next retry.
  • timeout is the maximum execution window for one handler attempt.
  • hook is app-owned behavior that wraps one handler attempt.
  • execution lease is a TTL-backed lock around one handler attempt for a logical job key.
  • terminal failure means the work should not be retried automatically.
  • dead letter is a durable terminal delivery state, currently owned by the outbox.

Error reporting follows the same terminal boundary. Retry attempts stay in logs and instrumentation; exhausted or non-retryable work becomes an incident. Runtime owners can use tryReportException(...) when reporting must never replace application behavior:

import { tryReportException } from "@beignet/core/error-reporting";

await tryReportException({
  reporter: ctx.ports.errorReporter,
  error,
  reportOptions: {
    mechanism: "app.import",
    tags: { "beignet.kind": "task" },
  },
});

Best-effort capture and its failure observer are each bounded to one second by default. Set timeoutMs to a different positive duration, or explicitly use false only for a reporter that is intentionally allowed to block the owning runtime boundary. Timeouts surface to onReporterError as ErrorReportingTimeoutError and otherwise resolve to undefined.

redactErrorReportOptions(...) applies Beignet's shared sensitive-key rules to structured user, tag, context, and extra metadata. It intentionally does not rewrite the original exception message or stack.

Jobs may also declare dispatch-time uniqueness and execution leases:

import {
  createJobExecutionLeaseHook,
  createInlineJobDispatcher,
  createJobs,
  createUniqueJobDispatcher,
  type JobDef,
  retry,
} from "@beignet/core/jobs";
import type { LocksPort } from "@beignet/core/locks";
import { z } from "zod";

type AppContext = {
  ports: {
    billing: {
      syncAccount(
        accountId: string,
        options?: { signal?: AbortSignal },
      ): Promise<void>;
    };
    locks: LocksPort;
  };
};

const { defineJob } = createJobs<AppContext>();

const syncAccountPayloadSchema = z.object({
  accountId: z.string().min(1),
});

const syncAccountExecutionLease = createJobExecutionLeaseHook<
  JobDef<"billing.sync-account", typeof syncAccountPayloadSchema, AppContext>,
  AppContext
>({
  locks: ({ ctx }) => ctx.ports.locks,
  key: ({ payload }) => payload.accountId,
  ttl: "5m",
});

export const SyncAccountJob = defineJob("billing.sync-account", {
  payload: syncAccountPayloadSchema,
  unique: ({ payload }) => ({
    key: payload.accountId,
    ttl: "10m",
  }),
  timeout: "30s",
  retry: retry.exponential({ attempts: 3 }),
  hooks: [syncAccountExecutionLease],
  async handle({ payload, ctx, signal }) {
    await ctx.ports.billing.syncAccount(payload.accountId, { signal });
  },
});

export function createJobsPort(ctx: AppContext) {
  return createUniqueJobDispatcher({
    jobs: createInlineJobDispatcher<AppContext>({ ctx }),
    locks: ctx.ports.locks,
  });
}

unique suppresses duplicate dispatches while the resolved lock key's TTL is active. It does not replace handler idempotency: providers may still execute a queued job more than once after a worker crash or retry. Dispatcher and transport boundaries validate the payload but preserve the original JSON-safe value for the next boundary. The runner parses immediately before handler execution, so transforming schemas produce the handler value exactly once.

timeout bounds each handler attempt. When the timeout expires, Beignet throws JobTimeoutError, aborts the handler's signal, and lets the job retry policy decide whether the timeout should retry. Cancellation is cooperative: a handler that ignores the signal can keep running while a retry-capable runner starts another attempt. Propagate the signal, keep the handler idempotent, and treat the timeout as terminal when overlapping attempts would be unsafe.

hooks wrap each handler attempt when the job runs through a Beignet dispatcher or worker helper. Runner-level hooks, such as createInlineJobDispatcher({ hooks }), wrap job-local hooks. Hook failures are classified by the same retry policy as handler failures. When a runner can report attempt metadata, hooks receive Beignet's one-based attempt and maxAttempts values. Direct job.handle(...) calls bypass hooks; use runJobHandler(...) or a dispatcher when a test needs hook behavior.

createJobExecutionLeaseHook(...) is the first built-in hook helper. It acquires a TTL-backed LocksPort lease for one handler attempt, then releases best effort in finally. It does not start renewal loops, so serverless entrypoints can use it with a shared locks provider; the TTL remains the safety boundary if the runtime terminates early. Unavailable leases skip by default, or can throw JobExecutionLeaseUnavailableError for retry classification.

Schedules do not own retry policies. They can carry provider attempt metadata through ScheduleRunContext.attempt, then dispatch jobs or outbox messages when the work needs Beignet-managed retry and dead-letter behavior.

Outbox drains emit first-class provider instrumentation for delivered, retried, and dead-lettered messages when you pass a devtools or instrumentation port to drainOutbox(...). Pass instrumentationContext when the worker has request or trace IDs that should connect the drain to devtools rows.

Best-effort work

BestEffortWorkPort lets application code request non-durable follow-up work without waiting for it in the originating operation:

import type { BestEffortWorkPort } from "@beignet/core/ports";

export type AppPorts = {
  bestEffortWork: BestEffortWorkPort;
};

ctx.ports.bestEffortWork.defer(() =>
  ctx.ports.workspaceBroadcast.publish(workspaceId, message),
);

Use it only when losing the callback does not change the operation's result, such as publishing a cache-invalidation hint after the authoritative write has committed. The adapter owns scheduler and callback error isolation. Use a job or transaction-scoped outbox when work must be retried or survive process termination.

Tests can use createRecordingBestEffortWork() directly or the default bestEffortWork returned by createTestPorts(...). Call fixture.flushBestEffortWork() to run the current queued batch deterministically; callbacks deferred by that batch remain pending until the next flush. A flush attempts the complete batch before rejecting with any callback failures.

Event subscriptions

Beignet event payloads are canonical JSON transport data. publishEvent(...), use-case event helpers, buffered recorder flushes, and outbox helpers parse the payload, convert the output to strict JSON, and validate the decoded JSON again. They throw EventTransportError before publication or durable recording when the output is not JSON-safe or changes under repeated validation. Use strings or numbers for timestamps rather than Date, and keep schema transforms idempotent. Keep validation deterministic and side-effect-free because Beignet can run it at producer and receiving boundaries. A transform such as z.string().trim() is valid because parsing its canonical output does not change it.

EventBusPort.subscribe(...) returns an EventSubscription with a ready promise and asynchronous, idempotent unsubscribe() method. Await ready before advertising that a process can receive events, and await unsubscribe() during shutdown. The memory adapter is ready immediately; transport adapters resolve readiness only after their initial subscription is active.

registerListeners(...) combines a listener registry into one subscription. It waits for every child subscription and applies one 10-second registration deadline by default. When setup fails or times out, Beignet starts every child cleanup within the remaining deadline. If cleanup does not settle in time, ready rejects with both the startup failure and a ListenerRegistrationCleanupTimeoutError instead of hanging startup. Set readyTimeoutMs when the process needs a different bounded startup policy. Normal teardown after successful readiness still awaits transport cleanup; apply a host-level shutdown deadline when required. Readiness is an initial lifecycle signal, not an ongoing health check or durability guarantee.

An app-owned EventBusPort adapter must call prepareEventPayloadForTransport(...) before publication. An in-process adapter delivers the returned payload and forwards the complete publishOptions object to subscribers. A serialized adapter encodes transportValue, sends documented metadata such as the trace carrier, and lets registerListeners(...) validate the decoded payload again at the receiving boundary.

Provider-contributed ports

Apps bind app-owned ports directly and defer the rest to providers with the curried definePorts<AppPorts>()({ bound, deferred }) form. Deferred keys boot as throwing placeholders, and createServer(...) fails startup with the unbound key list unless onUnboundPorts is set to "warn" or "ignore".

import { definePorts } from "@beignet/core/ports";
import type { AppPorts } from "@/ports";

export const initialPorts = definePorts<AppPorts>()({
  bound: { gate },
  deferred: ["db", "logger", "mailer", "storage"],
});

Use InferProviderPorts with an as const provider list to type the runtime ports without casts:

import type { InferProviderPorts } from "@beignet/core/providers";
import type { AppPorts } from "@/ports";
import type { providers } from "@/server/providers";

export type AppRuntimePorts = AppPorts & InferProviderPorts<typeof providers>;

Reusable provider packages should export a named ServiceProvider return type and use AnyProviderConfigSchema<Config> for its config generic. That keeps a private Zod or other Standard Schema implementation out of the package declaration while preserving the validated config output and exact contributed-port inference. App-local providers should keep their concrete schema inference because they do not have a package compatibility boundary.

App-local providers can declare required ports, app context, and service-context input through the curried createProvider() form. setup then receives typed ports and a createServiceContext factory that returns the app context:

import { createProvider } from "@beignet/core/providers";

export const appDatabaseProvider = createProvider<
  { db: DbPort<typeof schema>; devtools?: DevtoolsPort },
  AppContext,
  AppServiceContextInput
>()({
  name: "app-database",
  async setup({ ports, createServiceContext }) {
    const repositories = createRepositories(ports.db.drizzle);
    return { ports: repositories };
  },
});

Lifecycle hooks returned from setup should close over setup locals; a start(ctx) hook with an unannotated parameter keeps TypeScript from inferring the provided ports from the returned ports object.

Dev-default providers

Core ships provider factories for the mail and notifications ports so apps can defer those ports before choosing production infrastructure.

createMemoryMailerProvider(options?) contributes { mailer: MailerPort } backed by createMemoryMailer(...). Deliveries are captured in memory and recorded as mail.sent devtools events through the mail watcher when an instrumentation port is installed. Instrumentation records only the provider, recipient count, delivery ID, and duration; it does not record addresses, subjects, or message bodies. Options extend CreateMemoryMailerOptions (defaultFrom, now, id, onSend) plus a provider name that defaults to "memory-mailer".

The shared address formatter used by Beignet's Resend and SMTP providers rejects carriage returns and line feeds in email addresses and display names, and safely escapes quoted display names. This blocks address fields from injecting additional mail header lines; providers still own full email-syntax validation.

createInlineNotificationsProvider(options?) contributes { notifications: NotificationPort } backed by createInlineNotificationDispatcher(...). Channel handlers receive an app service context built lazily through the server context blueprint on each send, so registration order does not matter. One failed channel does not block the remaining channels. Inline sends return ordered sent, skipped, and failed results; queued dispatchers also return queued. Set failureMode: "throw" to reject after every channel has run. Options also accept an app-owned preferences evaluator, the dispatcher's onError result mapper, and a provider name that defaults to "inline-notifications".

// server/providers.ts
import { createMemoryMailerProvider } from "@beignet/core/mail";
import { createInlineNotificationsProvider } from "@beignet/core/notifications";

export const providers = [
  createMemoryMailerProvider({
    defaultFrom: "App <[email protected]>",
  }),
  createInlineNotificationsProvider(),
] as const;

Replace createMemoryMailerProvider(...) with a real mail provider such as @beignet/provider-mail-resend for production delivery. Production apps can keep the inline provider or define a central notification registry and a defineNotificationDeliveryJob(...). Install createQueuedNotificationsProvider(...) after the app's jobs provider to enqueue one independently retryable job per channel. Register the delivery job with every BullMQ/Inngest worker or outbox registry that can receive it.

// server/notifications.ts
import {
  defineNotificationDeliveryJob,
  defineNotificationRegistry,
} from "@beignet/core/notifications";
import type { AppContext } from "@/app-context";
import { WelcomeNotification } from "@/features/users/notifications";

export const notificationRegistry = defineNotificationRegistry<AppContext>([
  WelcomeNotification,
]);

export const DeliverNotificationJob =
  defineNotificationDeliveryJob<AppContext>({
    registry: notificationRegistry,
  });
// server/index.ts
import { createQueuedNotificationsProvider } from "@beignet/core/notifications";
import { createNextServer, createNextServerLoader } from "@beignet/next";

export const getServer = createNextServerLoader(async () => {
  const { providers } = await import("./providers");
  const { DeliverNotificationJob } = await import("./notifications");

  return createNextServer({
    // ...
    providers: [
      ...providers,
      createQueuedNotificationsProvider({
        deliveryJob: DeliverNotificationJob,
      }),
    ],
  });
});

The delivery job defaults to three attempts with exponential backoff. The queued dispatcher validates notification payloads before enqueueing and uses the app's existing jobs port, so the same setup works with direct job providers or createOutboxJobDispatcher(...).

Entitlements

Use @beignet/core/entitlements for product access decisions derived from app-owned billing or plan state. The resolver maps durable app state to allow/deny decisions; requireEntitlement(...) enforces the decision from a use case and throws a framework-owned 403 by default.

import {
  createEntitlements,
  type EntitlementDecisionObserver,
  requireEntitlement,
} from "@beignet/core/entitlements";
import { createTenant } from "@beignet/core/ports";
import { createTenantScope } from "@beignet/core/tenancy";

function createBillingEntitlements(
  billing: BillingRepository,
  recordDecision?: EntitlementDecisionObserver,
) {
  return createEntitlements({
    async inspect(input) {
      if (input.subject.type !== "tenant") return false;
      const account = await billing.findByTenantScope(
        createTenantScope(createTenant(input.subject.id)),
      );
      return account?.status === "active";
    },
    onDecision: recordDecision,
  });
}

await requireEntitlement(ctx, {
  entitlement: "todos.create",
  subject: { type: "tenant", id: tenantId },
});

onDecision is diagnostic only. Observer errors are ignored and cannot change the entitlement result.

Feature flags

Use @beignet/core/flags for typed feature flag definitions and provider-neutral evaluation. Flags always carry a default value, and provider failures return that default instead of throwing into product workflows.

import { defineFlag, defineFlags } from "@beignet/core/flags";

export const billingFlags = defineFlags({
  newCheckout: defineFlag.boolean("billing.new-checkout", {
    default: false,
  }),
});

const enabled = await ctx.ports.flags.evaluate(billingFlags.newCheckout, {
  context: {
    targetingKey: ctx.actor.id,
    tenant: ctx.tenant,
    requestId: ctx.requestId,
  },
});

Plain evaluation does not record exposure. Call recordExposure(...) explicitly when a user actually sees or can be affected by the flagged behavior. Use createMemoryFlags(...) or createStaticFlags(...) in tests, or install @beignet/provider-flags-openfeature for production providers. String and number flags widen to string and number by default; pass a generic when an app wants a closed variant union.

Error reporting

Use @beignet/core/error-reporting for provider-neutral exception and message capture. The port accepts severity, tags, user, contexts, extra metadata, and request/trace correlation IDs.

import { createMemoryErrorReporter } from "@beignet/core/error-reporting";
import { createErrorReportingHooks } from "@beignet/core/server";
import type { AppContext } from "@/app-context";

await ctx.ports.errorReporter.captureException(error, {
  level: "error",
  requestId: ctx.requestId,
  traceId: ctx.traceId,
  tags: { feature: "billing" },
});

const errorReporter = createMemoryErrorReporter();

export const hooks = [createErrorReportingHooks<AppContext>()];

Use createMemoryErrorReporter(...) in tests, createNoopErrorReporter() when an app needs a bound port without capture, and createErrorReportingHooks(...) in server/index.ts to capture unexpected HTTP failures without changing response mapping. Install @beignet/provider-error-reporting-sentry for production providers.

Locks and leases

Use @beignet/core/locks for provider-neutral lease-backed lock coordination. Locks prevent overlapping schedules, singleton jobs, cache stampedes, and short critical sections across multiple workers or servers.

import { createMemoryLocks } from "@beignet/core/locks";

await ctx.ports.locks.withLease(
  "schedule:daily-report",
  { ttlMs: 60_000, waitMs: 0 },
  async ({ lease }) => {
    await runDailyReport(ctx, { fencingToken: lease.fencingToken });
  },
);

const locks = createMemoryLocks();

To resume ownership in a later invocation, call locks.restore(key, ownerToken, { ttlMs, expiresAt?, fencingToken? }) with persisted state. The required ttlMs becomes the default for renew(); omitted expiry and fencing metadata stay unknown. Stale handles cannot delete or renew a newer owner's lease.

Use createMemoryLocks(...) in tests, createMemoryLocksProvider() for local provider wiring, or install @beignet/provider-locks-redis for production leases.

Search

Use @beignet/core/search for provider-neutral search index definitions, document indexing, and querying searchable read models.

import { defineSearchIndex } from "@beignet/core/search";

const issueSearchIndex = defineSearchIndex("issues", {
  searchableAttributes: ["key", "title", "description"],
  filterableAttributes: ["tenantId", "status"],
  sortableAttributes: ["createdAt"],
});

await ctx.ports.search.indexDocuments(issueSearchIndex, issueDocument);

const results = await ctx.ports.search.search(issueSearchIndex, {
  query: "billing",
  filters: { tenantId },
  sort: ["createdAt:desc"],
  limit: 20,
});

Use createMemorySearch(...) in tests, createMemorySearchProvider() for local provider wiring, or install @beignet/provider-search-meilisearch for production search. Search instrumentation records the index, result count, and query length. It does not record query text or document bodies. Provider adapters may require query fields to be declared in the index metadata. For Meilisearch, filters and facets must use filterableAttributes, and sort must use sortableAttributes.

Storage

Use StoragePort for provider-neutral object storage and createMemoryStorage() in tests. Storage keys are relative object paths with one shared contract across memory, local disk, S3, and Vercel Blob adapters.

Custom storage adapters can reuse the same validation, prefix, and public-URL behavior:

import {
  assertValidStorageKey,
  createStoragePublicUrl,
  normalizeStorageKeyPrefix,
  prefixStorageKey,
} from "@beignet/core/ports";

assertValidStorageKey("projects/report.json");

const keyPrefix = normalizeStorageKeyPrefix("/production/");
const providerKey = prefixStorageKey({
  keyPrefix,
  key: "projects/report.json",
});
const publicUrl = createStoragePublicUrl({
  publicBaseUrl: "https://assets.example.com",
  key: providerKey,
});

The shared assertion rejects empty keys, control characters, leading or trailing slashes, backslashes, empty segments, and . / .. segments. Provider adapters may add narrower restrictions for their own internal namespaces.

StoragePort.get(...) returns a one-shot StorageObjectBody. Consume one of its body methods, or call await object.cancel() when only inspecting metadata so providers can release unread streams, sockets, or file snapshots. Prefer stat(...) for metadata-only lookups.

Uploads

Use @beignet/core/uploads for typed file workflows above StoragePort. Upload definitions own metadata validation, authorization, storage keys, file constraints, direct-upload signing, and completion hooks.

import { createUploads } from "@beignet/core/uploads";
import { z } from "zod";

const { defineUpload } = createUploads<AppContext>();

export const issueAttachmentUpload = defineUpload("issues.attachment", {
  metadata: z.object({ issueKey: z.string() }),
  file: {
    contentTypes: ["application/pdf", "text/plain"],
    maxSizeBytes: 5 * 1024 * 1024,
    checksum: { algorithm: "sha256" },
  },
  authorize({ ctx }) {
    return ctx.actor.type === "user";
  },
  key({ ctx, metadata, uploadId }) {
    const actorId = ctx.actor.type === "user" ? ctx.actor.id : "anonymous";
    return `issues/${actorId}/${metadata.issueKey}/attachments/${uploadId}`;
  },
  async verifyFile({ ctx, file }) {
    const scan = await ctx.ports.fileScanner.scanObject(file.key);

    return scan.clean
      ? true
      : { valid: false, reason: "Upload did not pass scanning." };
  },
  async onComplete({ ctx, files }) {
    await ctx.ports.issueAttachments.upsertByUploadId({
      id: files[0]!.uploadId,
      key: files[0]!.key,
    });
  },
});

For supported media types, uploads verify the declared content type against the file signature before completion. Set contentTypeVerification: false only for workflows that intentionally accept mismatched supported file types. Direct uploads can require a SHA-256 checksum with checksum: { algorithm: "sha256" }; browser clients need a client-safe manifest so @beignet/core/uploads/client can compute the digest before prepare. Use verifyFile(...) for app-owned scanning, moderation, and quarantine decisions that run after the object exists in storage and before onComplete(...). Server uploads authorize each file before reading its bytes for signature or checksum verification. If key derivation, storage, or verification fails before onComplete(...) begins, the router deletes every object already stored by that request before returning the original error. Cleanup failures are instrumented as upload.server.cleanup.failed. Once app-owned completion begins, Beignet leaves the objects in place because the app may already have persisted durable references and therefore owns transaction or compensation. Direct-upload objects likewise remain app-owned because they existed before the completion request.

Direct-upload completion is stateless. Beignet does not retain issuance or single-use state between prepare and complete, so keys must include the relevant actor, tenant, or resource owner and onComplete(...) must be idempotent by upload ID or object key. Use an app-owned issuance table when a workflow requires single-use completion or revocation.

createUploadRouter(...) bounds JSON request bodies and server-handled multipart bodies. Multipart limits are enforced against both a declared Content-Length and the bytes actually read, so chunked requests cannot bypass limits.multipartMaxBytes before formData() parsing.

Upload route failures use Beignet's flat { code, message, details? } error body. The typed upload client maps that response to UploadClientError with the same code, status, and details.

Webhooks

Use @beignet/core/webhooks for provider-neutral inbound webhook definitions, raw-body verification, typed event payload catalogs, and test verifiers.

import {
  createHmacWebhookVerifier,
  defineWebhook,
} from "@beignet/core/webhooks";
import { z } from "zod";

export const issueWebhook = defineWebhook("issues.provider", {
  provider: "provider",
  events: {
    "issue.created": z.object({
      id: z.string(),
      type: z.literal("issue.created"),
      issueId: z.string(),
    }),
  },
  verifier: createHmacWebhookVerifier({
    secret: process.env.PROVIDER_WEBHOOK_SECRET ?? "",
    signatureHeader: "x-provider-signature",
    signaturePrefix: "sha256=",
    timestamp: {
      header: "x-provider-timestamp",
      toleranceSec: 300,
    },
  }),
});

Use createMemoryWebhookVerifier(...) in tests and createWebhookRoute(...) from @beignet/next to expose raw-body webhook routes in Next.js apps. Use a provider package such as @beignet/webhooks-github or @beignet/webhooks-stripe when a vendor has signature semantics beyond the generic HMAC verifier. For billing flows backed by ctx.ports.payments, use @beignet/core/payments with createPaymentWebhookRoute(...) from @beignet/next instead of a generic webhook catalog.

Feature webhook definitions stay provider-free: defineWebhook(...) catalogs are contract-reachable code, and contract-reachable code cannot import @beignet/provider-* packages — beignet lint enforces this dependency direction. Attach provider verifiers at the route boundary through the verify option of createWebhookRoute(...); the inline verifier: option on defineWebhook(...) is reserved for the core verifiers (createHmacWebhookVerifier(...), createMemoryWebhookVerifier(...)) and for tests.

Generic webhook catalogs reject verified event types that are not declared in events by default. Set allowUnknownEvents: true on createWebhookRoute(...) or verifyWebhook(...) only for broad provider endpoints that intentionally acknowledge valid events the app does not handle. When a generic HMAC provider signs a timestamp header or payload field, pass timestamp to reject replayed deliveries outside the configured tolerance. Header mode authenticates the exact <timestamp>.<rawBody> bytes; payload mode authenticates the raw body containing the timestamp.

Provider metadata

Reusable provider packages should declare static metadata in package.json under beignet.provider. That manifest metadata is package-owned and side-effect-free, so CLI diagnostics can inspect installed provider packages without importing provider implementation code.

{
  "beignet": {
    "provider": {
      "displayName": "Cache provider",
      "ports": ["cache"],
      "appPorts": [{ "name": "cache", "type": "CachePort" }],
      "env": ["CACHE_URL", "CACHE_REGION"],
      "requiredEnv": ["CACHE_URL"],
      "requiredTables": ["cache_entries"],
      "registration": {
        "required": true,
        "tokens": ["createCacheProvider"]
      },
      "watchers": ["cache"]
    }
  }
}

env lists all variables the provider may read. requiredEnv is the subset that beignet doctor --strict should require in app config. requiredTables lists database tables the provider always needs when it is installed and used; doctor checks app schema, migrations, and database setup files for those names. When a provider supports mutually exclusive credential paths, use requiredEnvAlternatives instead of requiredEnv. Each nested array is one complete configuration; doctor, provider audit, and preflight accept the provider when any one is complete:

{
  "env": ["API_TOKEN", "OIDC_CLIENT_ID", "OIDC_TOKEN"],
  "requiredEnvAlternatives": [
    ["API_TOKEN"],
    ["OIDC_CLIENT_ID", "OIDC_TOKEN"]
  ]
}

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 can declare registration.severity: "hint" instead, so an installed-but-unregistered package is reported as an informational hint that never fails doctor, even in strict mode. Use parseProviderPackageMetadata(...) to validate manifest metadata before publishing a provider package.

The package manifest is the sole provider metadata source. Runtime provider objects define lifecycle behavior and a diagnostic name; they do not repeat package facts. App-local providers therefore need no metadata declaration unless they are published as a reusable package, in which case add the manifest to that package.

Tasks

Use @beignet/core/tasks for app-owned operational entrypoints such as backfills, maintenance work, and one-off repair scripts. Tasks are not HTTP routes and are not background jobs; they are explicit functions a CLI or worker can run with parsed input and an application context. Run them with runTask(...) or beignet task run, and collect them with defineTasks(...).

import { createTasks } from "@beignet/core/tasks";
import { z } from "zod";
import type { AppContext } from "@/app-context";

const { defineTask } = createTasks<AppContext>();

export const backfillSearchTask = defineTask("posts.backfill-search", {
  input: z.object({
    dryRun: z.boolean().default(true),
  }),
  async handle({ input, ctx }) {
    ctx.ports.logger.info("Backfill started", {
      dryRun: input.dryRun,
    });
  },
});

Feature-owned task files should usually call use cases, repositories, or ports rather than hiding business rules inside a script.

Key concepts

Contract

A contract is the single source of truth for an API endpoint. It describes:

  • HTTP method and path (with path parameters)
  • Path parameters, query parameters, request headers, and request body schemas
  • Response schemas (per status code, including error responses)
  • Metadata for auth, rate limiting, idempotency, etc.

Contract group

A contract group allows you to share configuration across related endpoints, such as a common namespace, route metadata, headers, and shared response schemas.

Usage

Defining contracts

import { z } from "zod";
import {
  defineContractGroup,
  defineQueryTransport,
  query,
} from "@beignet/core/contracts";

// Create a contract group for related endpoints
const todos = defineContractGroup()
  .namespace("todos")
  .prefix("/api/todos")
  .meta({ auth: "required" })
  .headers(z.object({
    authorization: z.string().startsWith("Bearer "),
  }));

// Define schemas
const TodoSchema = z.object({
  id: z.string(),
  title: z.string(),
  completed: z.boolean(),
});

const CreateTodoRequest = z.object({
  title: z.string().min(1),
  completed: z.boolean().optional(),
});

// Define contracts
export const getTodo = todos
  .get("/:id")
  .pathParams(z.object({ id: z.string() }))
  .responses({ 200: TodoSchema })
  .errors({
    TodoNotFound: {
      code: "TODO_NOT_FOUND",
      status: 404,
      message: "Todo not found",
      details: z.object({ id: z.string() }),
    },
  });

export const createTodo = todos
  .post("/")
  .body(CreateTodoRequest)
  .responses({ 201: TodoSchema });

export const listTodos = todos
  .get("/")
  .query(
    z.object({
      completed: z.boolean().optional(),
      limit: z.number().int().optional(),
    }),
    defineQueryTransport({
      completed: query.boolean(),
      limit: query.integer(),
    }),
  )
  .responses({ 200: z.array(TodoSchema) });

Clients and OpenAPI generation infer required path argument keys from literal path templates. Use .pathParams(...) when you want runtime validation, coercion, richer OpenAPI schemas, or parameter descriptions.

Query schemas define logical values and validation; query transports define URL encoding. The same explicit transport drives typed-client encoding, server decoding, and OpenAPI form or deepObject metadata. Use the scalar helpers query.string(), query.number(), query.integer(), query.boolean(), query.dateTime(), and query.date(). Arrays repeat one scalar field, and query.deepObject(...) supports one flat object. Empty collections are omitted unless the array or object transport opts into { empty: "preserve" }, a versioned Beignet extension for typed clients.

query.integer() accepts JavaScript safe integers and publishes that range in OpenAPI.

The client serializes schema input values and, when validateInput: true is enabled, validates them without using transformed output as the query wire value. The server decodes the transport once and then runs the Standard Schema, so handlers receive defaults and transformed schema outputs. Keep HTTP wire conversion in the transport rather than a schema transform.

createServer(...) enforces registration-time guarantees: each method + path may only be registered once, contract names must be unique across the route registry because typed clients, OpenAPI operations, and devtools key on them, and an introspectable .pathParams(...) object schema must declare exactly the :param keys from the path template. Mismatches fail server startup with the contract name and path. Opaque Standard Schemas skip that registration-time key comparison; OpenAPI falls back to required string parameters from the literal path template unless a custom schema introspector is supplied. At dispatch time, a request that matches a registered path with an unregistered method receives a framework-owned 405 METHOD_NOT_ALLOWED response with an Allow header listing the registered methods. GET routes also serve HEAD when no explicit HEAD route exists; explicit HEAD routes take precedence, and every HEAD response is bodyless.

Runtime integrity

Workflow artifacts are explicit too. Use createRuntimeIntegrity(...) when an app should fail startup if a listener, schedule, task, or outbox event/job is listed in the app manifest but missing from the runtime registries:

import {
  createRuntimeIntegrity,
  defineRuntimeManifest,
  defineRuntimeRegistries,
} from "@beignet/core/server";
import { postEvents } from "@/features/posts/domain/events";
import { postJobs } from "@/features/posts/jobs";
import { postListeners } from "@/features/posts/listeners";
import { listeners } from "@/server/listeners";
import { outboxRegistry } from "@/server/outbox";

export const runtimeIntegrity = createRuntimeIntegrity({
  manifest: defineRuntimeManifest({
    listeners: [...postListeners],
    outbox: {
      events: [...postEvents],
      jobs: [...postJobs],
    },
  }),
  registries: defineRuntimeRegistries({
    listeners,
    outbox: outboxRegistry,
  }),
});

Pass integrity: runtimeIntegrity to createServer(...) or createNextServer(...). The check is pure and serverless-safe: it compares imported definitions and registries in memory, without filesystem scanning, provider calls, database access, worker startup, or background loops. Use mode: "warn" to log findings without failing boot.

Contract path templates intentionally support concrete segments and single-segment params such as :id and [id]. Framework or platform catch-all route files can expose a central Beignet handler, but individual contracts should stay on explicit paths; catch-all contract patterns such as /files/[...path] are rejected.

For routes that cannot be contracts at all — third-party callback endpoints with externally defined request shapes, signature-verified webhooks, streaming endpoints that own body consumption — server.rawRoute({ name, method, path, metadata, hooks }).handle(fn) builds a handler that still runs the whole pipeline (hooks, context creation, instrumentation, framework error mapping) without contract parsing or validation. The request body stays unconsumed for the handler, metadata feeds metadata-driven hooks such as rate limiting exactly like contract metadata, and the route is not added to the registry — the adapter mounts the returned handler at the route's own path.

Server-sent events

createServerSentEventResponse(...) creates a portable Fetch Response for an SSE endpoint. Use it from a contract handler or raw route after application-owned authentication and authorization:

import { createServerSentEventResponse } from "@beignet/core/server";

return createServerSentEventResponse({
  signal: req.signal,
  maxLifetimeMs: 240_000,
  start({ send }) {
    send({ event: "reconcile", data: { workspaceId } });
    const subscription = ctx.ports.workspaceBroadcast.subscribe(
      workspaceId,
      (change) => {
        send({ event: "changed", data: change });
      },
    );
    return { close: () => subscription.unsubscribe() };
  },
  onError: (error) => ctx.ports.logger.error("SSE stream failed", { error }),
});

Event data is JSON-encoded. Optional event, id, and retry fields use standard SSE framing; retry sets the browser reconnection delay in milliseconds. comment(...) sends an explicit comment, and close() ends the response. send(...) and comment(...) return true when they enqueue a frame and false after closure, when framing or encoding fails, or when the unread byte limit would be exceeded. Event names and IDs must fit on one line, and IDs cannot contain null characters. A producer, framing, encoding, stream, or buffer-overflow failure reports through onError and closes the connection.

Heartbeat comments default to 25 seconds; heartbeatMs: false disables them. maxLifetimeMs is opt-in. Both options accept false or an integer from 1 through 2_147_483_647; retry accepts an integer from 0 through the same maximum. start(...) receives a stream-scoped signal that aborts whenever the response closes. Pass it to subscription APIs that perform asynchronous setup. start(...) may return a cleanup callback, a closeable subscription, a promise of either, or nothing. If it returns cleanup, request abort, response cancellation, explicit close, and maximum lifetime invoke that cleanup exactly once. onError also observes cleanup failures without creating an unhandled rejection. When stream closure aborts asynchronous setup, an expected AbortError rejection is treated as cancellation rather than a producer failure; other setup rejections still reach onError.

Unread encoded frames are bounded to 1_048_576 bytes by default. Set maxBufferedBytes to an integer from 1 through 2_147_483_647 when the endpoint has a verified frame-size requirement. If one frame or the accumulated unread queue would exceed the limit, the helper reports a RangeError and closes the connection; the limit remains active even when maxLifetimeMs is disabled. Response-body cancellation waits for cleanup that has already been registered, but it does not wait indefinitely for pending asynchronous setup. The stream signal lets setup stop cooperatively, and any cleanup returned after closure still runs exactly once.

The response always sets Content-Type: text/event-stream, Cache-Control: no-store, no-transform, and X-Accel-Buffering: no, removes Content-Length and hop-by-hop streaming headers, and preserves other custom headers supplied through headers. The helper does not own authorization, replay, durability, distributed connection limits, or client reconciliation. Keep authoritative state elsewhere and reconcile on initial connection and reconnect when messages are best-effort hints.

Use a contract handler when the stream belongs in the route registry and OpenAPI document. Use a raw route when the endpoint is transport-only and the app owns its request and response shape. Document a contract stream with a .responses({ 200: null }) success schema and an OpenAPI text/event-stream media override.

Use .headers(...) for request headers that are part of the endpoint contract. Declare header keys in lowercase; server and client runtime matching is case-insensitive.

Request bodies are supported for POST, PUT, and PATCH contracts only. JSON bodies require Content-Type: application/json; otherwise the runtime passes the body to validation as text. When a missing content type accompanies a valid JSON object or array that fails validation, the framework-owned error includes a targeted details.hint without changing the response code.

If you do not pass name, Beignet generates one from the HTTP method and full path:

defineContract({ method: "GET", path: "/users/:id" }).name;
// "getUsersById"

defineContract({ method: "POST", path: "/api/todos" }).name;
// "createTodos"

Auto-generated names ignore a leading /api segment, include path parameters as By..., and are used as defaults in places like React Query keys and OpenAPI operationIds. Pass name explicitly when you need a custom stable identifier.

Path prefixes

Use .prefix(...) on a contract group to compose shared URL path segments without repeating them on every route:

const api = defineContractGroup().prefix("/api/v1");

const todos = api
  .namespace("todos")
  .prefix("/todos");

export const listTodos = todos.get("/");
// GET /api/v1/todos

export const getTodo = todos.get("/:id");
// GET /api/v1/todos/:id

Prefixes compose immutably and normalize boundary slashes. namespace() controls resource identity for contract names, OpenAPI tags, and client cache grouping; prefix() only controls URL paths.

For public API versions, keep request and response shapes explicit with path prefixes. Header negotiation remains app-owned. Mark an old contract or whole version group with .deprecated(...) while it is still served:

const v1 = defineContractGroup()
  .namespace("legacyTodos")
  .prefix("/api/v1/todos")
  .deprecated({
    since: "2026-07-11T00:00:00Z",
    sunset: "2027-01-01T00:00:00Z",
    reason: "Use the current todos collection.",
    replacement: "/api/todos",
    documentation: "https://docs.example.com/migrations/todos-v1",
  });

The metadata sets OpenAPI deprecated: true, adds x-beignet-deprecation, and sends standard Deprecation, Sunset, and deprecation-documentation Link response headers. UTC ISO 8601 timestamps are validated when contracts are built or registered.

Test app fixtures

Use @beignet/core/testing to build app contexts and common memory ports without hand-rolling audit, event, job, mail, notification, outbox, storage, idempotency, logger, clock, and UOW setup in every test:

import { createUseCaseTester } from "@beignet/core/application";
import { createTestContextFactory, createTestPorts } from "@beignet/core/testing";
import {
  createTestTenant,
  createTestUserActor,
} from "@beignet/core/testing";

const fixture = createTestPorts<AppContext["ports"]>({
  base: initialPorts,
  overrides: {
    gate: initialPorts.gate,
    posts: { findById: async (id) => postRecord(id) },
  },
});
const createContext = createTestContextFactory<AppContext, AppContext["ports"]>({
  ports: fixture.ports,
  actor: createTestUserActor("user_test"),
  auth: { user: { id: "user_test" } },
  tenant: createTestTenant("tenant_example"),
});
const tester = createUseCaseTester<AppContext>(createContext);

The returned fixture exposes captured side effects such as events, dispatchedJobs, audit.entries, mailer.deliveries, notifications.deliveries, outbox.messages, and memory storage for assertions. Its default bestEffortWork port queues callbacks in pendingBestEffortWork; call flushBestEffortWork() to run the current batch.

overrides is typed as TestPortsOverrides<Ports>, which accepts typed partial ports without casts. The partial rule is one level deep: an object-valued port may supply only the members the test needs, and any missing member becomes a named throwing function (Test port "posts.update" was called but not provided.). Function-valued ports, class instances, and other exotic objects are supplied whole — nested config objects are not partial.

The default audit port is wrapped with createAmbientAuditLog(...), so entries recorded inside an active request context inherit actor, tenant, request ID, and trace ID exactly like production. fixture.audit still exposes the underlying memory port for entries assertions.

One-call test contexts

Use createTestContext(...) when a job, listener, schedule, notification, or task test needs a full app context instead of a repeated factory:

import { createTestContext } from "@beignet/core/testing";

const makeContext = createTestContext<AppContext>();

it("audits handled jobs", async () => {
  using fixture = makeContext({
    ports: { issues: { findById: async (id) => issueRecord(id) } },
  });

  await IndexIssueJob.handle({ job: IndexIssueJob, payload, ctx: fixture.ctx });

  expect(fixture.audit.entries).toMatchObject([
    { action: "jobs.issues.index", requestId: "test-request" },
  ]);
});

The fixture assembles ctx with actor (default createTestSystemActor("test-system")), tenant, request ID, trace ID, auth, ports, and a live bound ctx.gate. It also enters the ambient request context so ambient enrichment (such as the default audit port) behaves like the server; using (or an explicit dispose()) clears it:

let fixture: ReturnType<ReturnType<typeof createTestContext<AppContext>>>;

afterEach(() => {
  fixture.dispose();
});

Pass ambient: false to skip ambient entry. Reading an app port that is neither a kit default nor supplied throws a named error (App port "tweets" is not bound in this test context.), so partial port wiring fails on use instead of failing silently.

Transactional domain events

When a use case records domain events through a buffered recorder on the transaction ports, pass transaction.outbox: true to enqueue tx.events to ports.outbox after commit and clear them after rollback:

import { createDomainEventRecorder } from "@beignet/core/ports";

const fixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
  transaction: {
    ports: (ports) => ({ ...ports, events: createDomainEventRecorder() }),
    outbox: true,
  },
});

transaction.outbox requires transaction.ports to include an events recorder created by createDomainEventRecorder(); the kit throws a named error otherwise. createOutboxEventRecorder(...) writes immediately through a transaction-scoped outbox port and is intentionally not a buffered recorder.

Sharing the server context blueprint

Declare the context blueprint once with defineServerContext(...) from @beignet/core/server and keep it in a canonical server/context.ts file. The same value round-trips through createServer(...) adapters and createTestApp(...) from @beignet/web/testing with full inference:

// server/context.ts
import { defineServerContext } from "@beignet/core/server";

export const appContext = defineServerContext<AppContext, AppPorts>()({
  gate: (ports) => ports.gate,
  request: async ({ req, ports, requestId, trace }) => ({
    actor: await resolveActor(req),
    auth: null,
    requestId,
    ...trace,
    ports,
  }),
  service: ({ ports, requestId, trace }) => ({
    actor: createServiceActor("app-service"),
    auth: null,
    requestId,
    ...trace,
    ports,
  }),
});
// server/index.ts
const server = await createNextServer({ ports, routes, context: appContext });

// features/<feature>/tests/routes.test.ts
import { createTestApp } from "@beignet/web/testing";

const app = await createTestApp({ ports, routes, context: appContext });

The service factory powers two server entrypoints:

  • server.createServiceContext(...) returns the built context and enters the ambient correlation frame for the rest of the caller's async execution. Use it from long-lived runtimes only: servers, workers, and test runners.
  • server.runServiceContext(...) builds the same context and runs a callback inside a scoped ambient frame, returning the callback's result. Use it from plain scripts such as seeds and one-off maintenance work — the createServiceContext(...) entrypoint relies on AsyncLocalStorage.enterWith, and resuming that frame across top-level await crashes Bun 1.3.x in plain scripts.
// scripts/seed.ts (plain script, top-level await)
const server = await createServer({ ports, context: appContext });

await server.runServiceContext({ tenantId: "tenant_demo" }, async (ctx) => {
  await seedDemoData(ctx);
});

Both entrypoints require context.service in the blueprint, generate fresh requestId and trace values per call, and expose the service actor and tenant on the ambient request context so audit and instrumentation wrappers observe them at record time.

Tenant scopes

Use @beignet/core/tenancy when a repository method should be scoped to the current tenant without accepting arbitrary tenant IDs from callers:

import {
  requireTenantScope,
  tenantScopeId,
  type TenantScope,
} from "@beignet/core/tenancy";

export interface TodoRepository {
  create(input: CreateTodoInput, scope: TenantScope): Promise<Todo>;
}

const scope = requireTenantScope(ctx);
await ctx.ports.todos.create(input, scope);

const tenantId = tenantScopeId(scope); // adapter boundary

Apps still own tenant resolution and tenant data modeling. TenantScope only brands the already-resolved ctx.tenant value for app-facing repository boundaries. beignet doctor --strict checks generated tenant-scoped Drizzle repositories, explicit raw tenantId repository boundaries, and scoped tenantId/workspaceId predicates as a conservative drift detector.

Testing providers

Use installProviderForTest(...) to run provider setup against test ports without hand-rolling setup, port merge, and lifecycle plumbing:

import type { CachePort } from "@beignet/core/ports";
import { installProviderForTest } from "@beignet/core/testing";
import { createRedisCacheProvider } from "@beignet/provider-cache-redis";

const { ports, result, start, stop } = await installProviderForTest(
  createRedisCacheProvider(),
  {
    config: { URL: "redis://localhost:6379" },
  },
);

const cache = ports.cache as CachePort;
await cache.set("posts:list", "[]");

await stop();

ports contains the base ports merged with provider-contributed ports, and result exposes the raw setup result for lifecycle-hook assertions. config is passed to setup as-is, matching server startup where config is validated before setup runs. Pass createServiceContext when the provider builds service contexts from runtime entrypoints.

Test factories and seeds

Use the same subpath to keep feature tests and demo seed data port-based. Factories build app-owned records, and optional persist functions write through the context you pass in:

import {
  createDatabaseTestHarness,
  createFactory,
  defineSeed,
  resetFactories,
  runSeeds,
} from "@beignet/core/testing";

const postFactory = createFactory("post", {
  defaults: ({ sequence }) => ({
    title: `Post ${sequence}`,
    content: "Created in a test.",
  }),
  persist: (ctx: AppContext, post) => ctx.ports.posts.create(post),
});

const demoPostsSeed = defineSeed("demo-posts", {
  run: async (ctx: AppContext) => {
    await postFactory.createList(ctx, 3);
  },
});

export async function seedDemoPosts(ctx: AppContext) {
  await runSeeds({ ctx, seeds: [demoPostsSeed] });
}

export function resetPostFactories() {
  resetFactories(postFactory);
}

For repository and persistence tests, compose the app-owned database fixture with the same factories and seeds:

const databaseHarness = createDatabaseTestHarness({
  create: createTestDatabase,
  ctx: (database) => ({ ports: database.ports }),
  reset: (database) => database.reset(),
  close: (database) => database.close(),
  factories: [postFactory],
  seeds: [demoPostsSeed],
});

afterEach(async () => {
  await databaseHarness.cleanup();
});

const { ctx } = await databaseHarness.setup({ seed: true });
const post = await postFactory.create(ctx, { title: "Database conventions" });

Keep factories and seeds app-owned. They should not import database clients, ORM table objects, or provider SDKs directly.

Port testing helpers

Use @beignet/core/testing when tests need stable actor, tenant, authorization, or audit assertions:

import {
  assertAuditEntry,
  createPolicyTester,
  createTestActivityContext,
  createTestTenant,
  createTestUserActor,
} from "@beignet/core/testing";

const activity = createTestActivityContext({
  actor: createTestUserActor("user_1", { role: "admin" }),
  tenant: createTestTenant("tenant_1"),
});

const tester = createPolicyTester({ policies: [postPolicy] });
await tester.assertMatrix([
  {
    name: "admin can publish",
    ctx: activity,
    ability: "posts.publish",
    subject: post,
    expected: "allow",
  },
]);

const permissions = await tester.gate.canMany(activity, {
  publish: ["posts.publish", post],
});
expect(permissions.publish).toBe(true);

assertAuditEntry(audit.entries, {
  action: "posts.publish",
  actorId: "user_1",
  tenantId: "tenant_1",
  resourceType: "post",
  resourceId: post.id,
});

createTestImpersonatedUserActor(...) is available for tes