Core framework primitives for Beignet
This package provides Beignet's framework primitives: contracts, server runtime, typed client, use cases, ports, domain helpers, app errors, config, events, idempotency, outbox, mail, notifications, schedules, uploads, pagination helpers, testing helpers, and OpenAPI generation.
npm install @beignet/core
# Use with your preferred Standard Schema library
npm install zod
# or
npm install valibot
# or
npm install arktype
This package requires TypeScript 5.0 or higher for proper type inference.
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/application |
Use case builder and test helpers |
@beignet/core/client |
Typed HTTP client |
@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/errors |
Error catalogs and response helpers |
@beignet/core/events |
Events and listeners |
@beignet/core/idempotency |
Retry-safe command, webhook, and job primitives |
@beignet/core/jobs |
Job definitions and inline job dispatch |
@beignet/core/mail |
Mail port and memory mailer |
@beignet/core/notifications |
Notification definitions, dispatchers, mail channels, and test adapters |
@beignet/core/openapi |
OpenAPI generation |
@beignet/core/outbox |
Durable event and job outbox |
@beignet/core/pagination |
Offset/cursor page types, normalizers, and result helpers |
@beignet/core/ports |
App-facing ports, auth, audit, policies, cache, storage, logging, and redaction |
@beignet/core/ports/testing |
Port and policy test helpers |
@beignet/core/providers |
Provider lifecycle and instrumentation primitives |
@beignet/core/schedules |
Scheduled task primitives |
@beignet/core/server |
Framework-agnostic server runtime and hook helpers |
@beignet/core/testing |
Test factories, seed definitions, and seed runners |
@beignet/core/uploads |
Upload definitions, router, signer port, and test signer |
@beignet/core/uploads/client |
Browser upload client for server and direct uploads |
A contract is the single source of truth for an API endpoint. It describes:
A contract group allows you to share configuration across related endpoints, such as a common namespace, authentication requirements, and shared response schemas.
import { z } from "zod";
import { createContractGroup } from "@beignet/core/contracts";
// Create a contract group for related endpoints
const todos = createContractGroup()
.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.coerce.number().optional(),
}))
.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.
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.
If you do not pass name, Beignet generates one from the HTTP method and full path:
createContract({ method: "GET", path: "/users/:id" }).name;
// "getUsersById"
createContract({ 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.
Use .prefix(...) on a contract group to compose shared URL path segments without repeating them on every route:
const api = createContractGroup().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.
Use @beignet/core/testing 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 { defineFactory, defineSeed, runSeeds } from "@beignet/core/testing";
const postFactory = defineFactory("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] });
}
Keep factories and seeds app-owned. They should not import database clients, ORM table objects, or provider SDKs directly.
Use @beignet/core/pagination to keep list use cases and repository ports
consistent without coupling them to an ORM:
import { normalizeOffsetPage } from "@beignet/core/pagination";
const page = normalizeOffsetPage(input, {
defaultLimit: 20,
maxLimit: 100,
});
return ctx.ports.posts.findMany({
page,
filters: { status: input.status },
sort: { field: "createdAt", direction: "desc" },
});
Beignet's convention is items for list contents and page for pagination
metadata. Keep filters and sort options app-owned plain objects.
Use @beignet/core/idempotency when a command, webhook, or job may be retried
and must not perform duplicate work:
import {
createIdempotencyFingerprint,
runIdempotently,
} from "@beignet/core/idempotency";
const result = await runIdempotently(ctx.ports.idempotency, {
namespace: "todos.create",
key: input.idempotencyKey,
scope: {
tenantId: ctx.tenant?.id,
actorId: ctx.actor?.id,
},
fingerprint: await createIdempotencyFingerprint(input, {
omit: ["idempotencyKey"],
}),
ttlSec: 60 * 60 * 24,
run: () => ctx.ports.uow.transaction((tx) => tx.todos.create(input)),
});
The memory store is useful for tests and local examples:
import { createMemoryIdempotencyStore } from "@beignet/core/idempotency";
const idempotency = createMemoryIdempotencyStore();
Production apps should back IdempotencyPort with atomic SQL or Redis storage.
For high-integrity workflows, prefer exposing a transaction-scoped
tx.idempotency port from the app Unit of Work so reservation, business writes,
audit records, domain-event records, and idempotency completion commit together.
Use @beignet/core/outbox when events or jobs must be recorded in the same
database transaction as the business write, then delivered later with retries:
import {
createOutboxEventRecorder,
defineOutboxRegistry,
drainOutbox,
} from "@beignet/core/outbox";
import {
createDrizzleTursoOutboxPort,
createDrizzleTursoUnitOfWork,
} from "@beignet/provider-drizzle-turso";
const uow = createDrizzleTursoUnitOfWork({
db,
createTransactionPorts: (tx) => {
const outbox = createDrizzleTursoOutboxPort(tx);
return {
posts: createPostRepository(tx),
events: createOutboxEventRecorder(outbox),
outbox,
};
},
});
const registry = defineOutboxRegistry({
events: [PostPublished],
jobs: [SendPostPublishedEmailJob],
});
await drainOutbox({
outbox: ctx.ports.outbox,
registry,
eventBus: ctx.ports.eventBus,
jobs: ctx.ports.jobs,
});
The outbox is at-least-once delivery. Use idempotent listeners or jobs when a duplicate delivery would be harmful.
Use metadata to drive cross-cutting concerns like authentication, rate limiting, and idempotency:
const sendMessage = messages
.post("/api/messages")
.body(SendMessageRequest)
.responses({ 201: SendMessageResponse })
.meta({
auth: "required",
idempotency: {
required: true,
header: "idempotency-key",
scope: "actor-tenant",
ttlSec: 300,
},
rateLimit: {
max: 60,
windowSec: 60,
scope: "user",
},
});
Add OpenAPI-specific metadata for documentation using the .openapi() method:
export const getTodo = todos
.get("/api/todos/:id")
.pathParams(z.object({ id: z.string() }))
.responses({ 200: TodoSchema })
.openapi({
summary: "Get a todo by ID",
description: "Retrieves a single todo item by its unique identifier",
tags: ["todos"],
deprecated: false,
operationId: "getTodoById",
externalDocs: {
url: "https://docs.example.com/todos",
description: "Todo documentation",
},
security: [{ bearerAuth: [] }],
});
Contracts expose their schemas for runtime introspection:
getTodo.schema.pathParams; // Path parameter schema
getTodo.schema.query; // Query parameter schema
getTodo.schema.body; // Request body schema
getTodo.schema.responses; // Response schemas by status code
getTodo.path; // "/api/todos/:id"
getTodo.pathTemplate; // "/api/todos/:id" (alias)
getTodo.method; // "GET"
getTodo.metadata; // { auth: "required", ... }
createContractGroup()Creates a new contract group for defining related endpoints.
const group = createContractGroup()
.namespace("myNamespace") // Optional resource namespace
.prefix("/api/v1") // Optional URL path prefix
.meta({ auth: "required" }) // Shared metadata
.headers(AuthHeaders) // Shared request headers
.errors({ // Shared catalog errors
TenantSuspended: errors.TenantSuspended,
});
Any non-empty response map is treated as a response contract. Include
successful statuses such as 200 or 201 alongside custom error statuses; use
responses: {} only when you want to skip response validation. Prefer
.errors(...) for expected business failures that should use Beignet's
standard error envelope.
| Method | Description |
|---|---|
.get(path) |
Define a GET endpoint |
.post(path) |
Define a POST endpoint |
.put(path) |
Define a PUT endpoint |
.patch(path) |
Define a PATCH endpoint |
.delete(path) |
Define a DELETE endpoint |
.pathParams(schema) |
Define path parameter schema |
.query(schema) |
Define query parameter schema |
.headers(schema) |
Define request header schema |
.body(schema) |
Define request body schema |
.responses({ ... }) |
Define or merge response schemas by status code |
.errors({ ... }) |
Declare route-owned catalog errors using Beignet's standard error envelope |
.meta(metadata) |
Add custom metadata |
.openapi(options) |
Add OpenAPI metadata (summary, tags, etc.) |
This package works with any Standard Schema compatible library:
OpenAPI generation currently requires Zod schemas, even though core contracts can use any Standard Schema-compatible library.
@beignet/next - Next.js server adapter@beignet/react-query - TanStack Query integration@beignet/react-hook-form - React Hook Form integration@beignet/react-uploads - React upload state and progress hooks@beignet/nuqs - URL query state integration with nuqs@beignet/devtools - Local request, provider, and audit timelineMIT