Routes and server
The server runtime matches requests to contracts, validates requests and responses, and runs your use cases with a typed per-request context.
Read this page when you are wiring route groups, choosing the Next.js or Web Fetch adapter, or configuring server options. If you are creating your first app, start with Quickstart. For what happens to a request between arrival and response, see Request lifecycle.
Creating a server
// server/index.ts
import { createNextServer, createNextServerLoader } from "@beignet/next";
import { initialPorts } from "@/infra/port-wiring";
import { appContext } from "@/server/context";
import { routes } from "@/server/routes";
export const getServer = createNextServerLoader(() =>
createNextServer({
ports: initialPorts,
context: appContext,
routes,
}),
);ports wires your application's dependency interfaces — databases, caches,
mailers — defined with definePorts. See Ports for defining and
deferring ports, and Providers for ready-made implementations.
The server also accepts the mapUnhandledError and onCaughtError error
options — see Errors — the instrumentation option covered in
Request lifecycle, and
requestBody.maxBytes for JSON/text contract route bodies. The default request
body limit is 1 MiB; requests over the limit return a framework-owned 413
with code PAYLOAD_TOO_LARGE.
Next.js integration
The @beignet/next adapter works with the Next.js App Router. Expose the
central handler from a catch-all API route:
// app/api/[[...path]]/route.ts
import { createApiRoute } from "@beignet/next";
import { getServer } from "@/server";
export const { DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT } =
createApiRoute(getServer);Next App Router requires literal named exports for each HTTP method, so
Beignet exposes createApiRoute(getServer) to return those exports while
keeping provider startup behind the memoized server loader. The catch-all file
belongs to the adapter layer; Beignet contracts themselves still use concrete
paths with optional single-segment params such as /posts/:id.
The Next adapter also ships helpers for common app glue: createOpenAPIHandler
and Swagger routes (see OpenAPI), Server Component context helpers,
upload routes, storage routes, devtools routes, outbox drain routes, and
createNextBestEffortWorkPort(...) for non-durable work scheduled through
after() on Next.js 15.1 or newer. See
Ports and adapters for the failure boundary
and provider wiring. Server Component contexts preserve request headers and
synthesize the standard cookie header from Next's cookies() store when
headers() does not expose one, so auth providers see the same cookie shape
they see in API routes.
Export contracts = contractsFromRoutes(routes) from server/routes.ts for
OpenAPI. For per-file server.route(contract).handle(...) handlers, pass an
explicit contract list instead.
Web Fetch runtimes
Use @beignet/web when the runtime accepts a standard Request and returns a
standard Response — Cloudflare Workers, Bun, Deno, Node fetch servers, and
route tests. createFetchServer(...) takes the same options as
createNextServer and exposes a framework-neutral server.fetch handler plus
server.runServiceContext(...) for scoped non-HTTP work; see the
@beignet/web README for setup.
Plain response objects default to JSON serialization. In Web Fetch and Next.js
runtimes, an explicit ReadableStream stays a stream even for JSON media
types. An explicit non-JSON Content-Type opts strings and binary values into
Web BodyInit serialization. Native Response remains available when a route
needs direct transport control.
All adapters share the same boundary: @beignet/core/server owns matching,
hooks, validation, error mapping, and response ownership, while the adapter
only converts between platform request/response types — implement the
HttpAdapter<NativeRequest, NativeResponse> shape (webFetchAdapter is the
reference) to target another runtime.
Context
Put your baseline context type in app-context.ts, then declare a context
blueprint that builds that shape. Feature route groups, use cases, hooks, and
tests should import AppContext from that file instead of redefining it.
// app-context.ts
import type { ActivityActor, ActivityTenant } from "@beignet/core/ports";
import type { TrustedRequestInfo } from "@beignet/core/server";
import type { TraceContext } from "@beignet/core/tracing";
import type { AppGate, AppPorts } from "@/ports";
import type { AuthSession } from "@/ports/auth";
export type AppContext = {
actor: ActivityActor;
auth: AuthSession | null;
gate: AppGate;
requestId: string;
requestInfo?: TrustedRequestInfo;
ports: AppPorts;
tenant?: ActivityTenant;
} & Partial<TraceContext>;Keep the runtime blueprint in server/context.ts so the server and route tests
reuse the same context construction:
// server/context.ts
import {
createAnonymousActor,
createServiceActor,
createTenant,
createUserActor,
} from "@beignet/core/ports";
import { defineServerContext } from "@beignet/core/server";
import type { TraceContext } from "@beignet/core/tracing";
import type { AppContext } from "@/app-context";
function resolveRequestTenant(auth: AppContext["auth"]) {
const tenantId = tenantIdFromAuthoritativeAuthClaims(auth);
return tenantId ? createTenant(tenantId) : undefined;
}
function tenantIdFromAuthoritativeAuthClaims(auth: AppContext["auth"]) {
const tenantId = auth?.session?.tenantId;
return typeof tenantId === "string" && tenantId.trim()
? tenantId.trim()
: undefined;
}
export type AppServiceContextInput =
| {
tenantId?: string;
}
| undefined;
export const appContext = defineServerContext<
AppContext,
AppContext["ports"]
>()({
gate: (ports) => ports.gate,
request: async ({ ports, req, requestId, requestInfo, trace }) => {
const auth = await ports.auth.getSession(req);
return {
actor: auth ? createUserActor(auth.user.id) : createAnonymousActor(),
auth,
requestId,
requestInfo,
...trace,
ports,
tenant: resolveRequestTenant(auth),
};
},
service: ({
ports,
input,
requestId,
trace,
}: {
ports: AppContext["ports"];
input: AppServiceContextInput;
requestId: string;
trace: TraceContext;
}) => ({
actor: createServiceActor("app-service"),
auth: null,
requestId,
...trace,
ports,
tenant: createTenant(input?.tenantId ?? "tenant_default"),
}),
});The context option receives this blueprint. The request factory runs on
every request: it receives ports, the raw request, and the server-resolved
requestId, requestInfo, and trace values, and returns the context fields
available to all handlers. requestInfo contains the request URL, origin,
protocol, host, and optional client IP after applying the server's
trustedProxy policy. It ignores forwarding headers by default. Service
contexts omit it because they do not originate from HTTP requests.
The server owns ctx.gate: declare which port provides it with
gate, and the server attaches a live gate that always authorizes against the
current actor and tenant — even after hooks elevate identity. Returning
gate from a factory is a compile error.
auth is the resolved provider session or null. actor is the durable
audit and authorization actor derived from that session, or from a service
identity in background contexts. tenant is optional; omit it when no tenant
is active instead of setting it to null.
Only use tenantIdFromAuthoritativeAuthClaims(...) when the auth provider
guarantees that the server-issued claim represents current membership. A
multi-tenant app without that guarantee must replace this seam with an
app-owned membership lookup before it creates TenantContext; never trust a
tenant ID echoed from the caller or stored in an unverified session field.
Configure trusted request metadata once in server/index.ts when every request
passes through a trusted edge:
createNextServer({
// The last entry is safe only when the trusted proxy appends it.
trustedProxy: { clientIp: "x-forwarded-for-last" },
context: appContext,
hooks: [createCsrfHooks(), createRateLimitHooks()],
// ...
});The request context and each server-hook phase receive the same resolved
requestInfo. CSRF and rate-limit hooks use it automatically. Logging
observers may read it explicitly, but Beignet does not automatically persist
client IPs in logs or audit entries.
An optional service factory powers the two service entrypoints used by
schedules, outbox drains, tasks, and background work; it receives fresh
requestId and trace values per call and typically defaults actor to
createServiceActor(...):
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. Plain scripts such as seeds and one-off maintenance work must use this entrypoint:createServiceContext(...)relies onAsyncLocalStorage.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)
await server.runServiceContext({ tenantId: "tenant_demo" }, async (ctx) => {
await seedDemoData(ctx);
});Acting as a user from non-HTTP entrypoints
The service input type is app-owned, so entrypoints that act on behalf of
a user — AI agents, queue consumers, imports, backfills — extend it instead
of hand-assembling a context. Resolve the user's real membership from the
database in the caller (never from the caller's claims), pass it through the
input, and let the factory build the same shape the request factory builds.
The server attaches the gate either way, so policies evaluate the
impersonated identity exactly as they would a signed-in request:
export type AppServiceContextInput =
| {
tenantId?: string;
/** Act as this user with their verified membership. */
asUser?: { id: string; role: WorkspaceRole; name?: string };
}
| undefined;
// In the service factory:
service: ({ ports, input, requestId, trace }) => ({
actor: input?.asUser
? createUserActor(input.asUser.id, { displayName: input.asUser.name })
: createServiceActor("app-service"),
auth: input?.asUser
? { user: { id: input.asUser.id } }
: null,
requestId,
...trace,
ports,
...(input?.tenantId ? { tenant: createTenant(input.tenantId) } : {}),
...(input?.asUser ? { membership: { role: input.asUser.role } } : {}),
}),// An agent capability acting for a verified user:
const membership = await server.ports.members.findMembership({
workspaceId,
userId,
});
if (!membership) throw forbidden();
const ctx = await server.createServiceContext({
asUser: { id: userId, role: membership.role },
tenantId: workspaceId,
});
await appendToPageUseCase.run({ ctx, input });Use the registry and executor from Agent capabilities when exposing multiple actions through an agent transport.
Never assemble a context object by hand or call gate.attach(...) from app
code — the server owns gate attachment and strips hand-attached gates with a
dev warning.
Apps without a gate on their context type can pass a plain request factory:
context: async ({ ports, requestId }) => ({ requestId, ports }).
Route registration
The canonical Beignet app route style binds route builders to AppContext
once in lib/routes.ts. Feature route files use
defineRouteGroup({ name, routes }), route entries bind a
contract directly to a use case ({ contract, useCase }), and routes that own
response headers, streaming, native Response values, or multi-status
handling implement a full handler ({ contract, handle }). The app-bound
builder preserves per-route contract and use-case type checks without repeated
generic arguments.
server/routes.ts composes groups with defineRoutes<AppContext>(...), and
server/index.ts passes the central routes list to the adapter. This gives
route inspection, OpenAPI, typed clients, lint, and doctor one shared route
registry.
// lib/routes.ts
import "@beignet/core/server-only";
import { createRoutes } from "@beignet/core/server";
import type { AppContext } from "@/app-context";
export const { defineRoute, defineRouteGroup } = createRoutes<AppContext>();// features/todos/routes.ts
import { defineRouteGroup } from "@/lib/routes";
import { getTodo, listTodos } from "@/features/todos/contracts";
import { getTodoUseCase, listTodosUseCase } from "@/features/todos/use-cases";
export const todoRoutes = defineRouteGroup({
name: "todos",
routes: [
{ contract: listTodos, useCase: listTodosUseCase },
{ contract: getTodo, useCase: getTodoUseCase },
],
});A binder route synthesizes the handler at registration time. The response
status is inferred when the contract declares exactly one 2xx response;
multiple 2xx responses require an explicit status. A sole path, query, or
body schema is passed through unchanged when no additional path, query, or
object body values are present, so scalar and array bodies retain their shape.
Multiple declared sources and inferred path values use the default object
merge. Use-case errors flow through the app error catalog exactly as they do
from handle routes.
Share schemas by reference and Beignet skips double validation: when a
contract's single input source schema, or its declared success response
schema, is the same object as the use case's .input(...) or .output(...)
schema, the redundant second parse is skipped only after the first layer
actually validated it. In particular, validate: { output: false } on the use
case keeps route response validation active. Reusing schemas by reference is
how the framework knows validation already happened.
The multiple-source input mapping — query lowest, then body, then path, headers
never merged — is exported as defaultBinderInput. Routes that read headers,
combine another source with a scalar or array body, or need any other input
shape declare an explicit input mapper over the parsed parts:
{
contract: resolveIssue,
hooks: [writerAuth.required()],
useCase: resolveIssueUseCase,
input: ({ path, headers }) => ({
key: path.key,
expectedVersion: headers["x-expected-version"],
}),
},Full handlers
handle remains the escape hatch for everything the binder intentionally does
not cover — response headers, streaming, native Response values, redirects,
and multi-status handling:
// features/todos/routes.ts
{
contract: exportTodos,
handle: async ({ ctx, query }) => {
const csv = await exportTodosUseCase.run({ ctx, input: query });
return new Response(csv, {
status: 200,
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": 'attachment; filename="todos.csv"',
},
});
},
},The handler object gives you req (Beignet's framework-neutral HTTP request),
ctx, the validated path, query, headers, and body parts, and
contract for metadata access — all typed from the contract definition.
Declared request headers are normalized to lowercase before validation: use
headers.authorization for parsed contract headers and req.headers for raw
transport access. Fetch adapters also expose the native request as req.raw
and its cancellation signal directly as req.signal.
Compose feature groups at the server boundary:
// server/routes.ts
import { contractsFromRoutes, defineRoutes } from "@beignet/core/server";
import type { AppContext } from "@/app-context";
import { todoRoutes } from "@/features/todos/routes";
export const routes = defineRoutes<AppContext>([todoRoutes]);
export const contracts = contractsFromRoutes(routes);defineRoutes flattens route groups before they are passed to the server, so
server/index.ts can stay focused on app composition.
Focused per-file routes
Use server.route(contract).handle(...) for focused per-file adapter routes —
webhooks, redirects, downloads, OpenAPI/devtools — that intentionally sit
outside the central route registry. Export an explicit contract list when such
a route should appear in OpenAPI or typed-client contract lists.
Raw routes
Some routes cannot be contracts at all: third-party callback endpoints with
externally defined request shapes (a Liveblocks room-auth endpoint, a Better
Auth callback), signature-verified webhooks, or streaming endpoints that own
body consumption. server.rawRoute(...) builds a handler for these that
still runs the whole pipeline — correlation, onRequest/beforeHandle/
beforeSend/afterSend hooks, context creation, instrumentation, and
framework error mapping — without contract parsing or validation. The request
body is left unconsumed, so the handler can verify a signature over the exact
raw bytes.
// app/api/liveblocks-auth/route.ts
import { getServer } from "@/server";
let roomAuth: ((req: Request) => Promise<Response>) | undefined;
export async function POST(req: Request) {
roomAuth ??= (await getServer())
.rawRoute({
name: "collab.roomAuth",
method: "POST",
path: "/api/liveblocks-auth",
metadata: {
rateLimit: { max: 300, windowSec: 60, scope: "user" },
},
})
.handle(async ({ req, ctx }) => {
const body = await req.text();
// Authorize the room from app data and return the provider token.
return { status: 200, body: { token: "..." } };
});
return roomAuth(req);
}name, method, and path identify the route to hooks, instrumentation,
and devtools; routing itself belongs to the file that mounts the handler.
metadata feeds metadata-driven hooks exactly like contract metadata, so
declared rate limits and idempotency apply without hand-rolled enforcement.
The Next.js route factories — createWebhookRoute,
createPaymentWebhookRoute, createScheduleRoute, and
createOutboxDrainRoute — run through this pipeline automatically when the
server they receive exposes rawRoute(...).
Beyond JSON
Beignet is JSON-first: returning a plain object from a handler produces a JSON
response and declared response schemas validate that JSON value. For
transport-level cases such as webhook signatures, downloads, plain text,
redirects, or streams, use the raw request readers and return a native web
Response:
import { getServer } from "@/server";
export async function POST(req: Request) {
const server = await getServer();
const handle = server.route(stripeWebhook).handle(async ({ req }) => {
const rawBody = await req.text();
const signature = req.headers.get("stripe-signature");
verifyWebhookSignature(rawBody, signature);
return { status: 200, body: { received: true } };
});
return handle(req);
}
export async function GET(req: Request) {
const server = await getServer();
const handle = server.route(downloadReport).handle(async () =>
new Response(await loadReportBytes(), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="report.pdf"',
},
}),
);
return handle(req);
}Native Response instances are transport-owned: they bypass JSON response
validation, and beforeSend hooks only merge header changes onto them. Use
{ status, body } when you want the response contract enforced; use
Response when the route owns transport details directly. See
Request lifecycle for the ownership
taxonomy and Hooks for the native-response hook rules.
Server-sent events
Use createServerSentEventResponse(...) when a contract handler or raw route
needs a portable Server-Sent Events response. It JSON-encodes event data,
emits heartbeat comments every 25 seconds by default, closes when req.signal
aborts, bounds unread event data to 1 MiB, and runs any cleanup returned by
start(...) exactly once. It also sets Content-Type: text/event-stream,
Cache-Control: no-store, no-transform, and X-Accel-Buffering: no.
// app/api/workspaces/[workspaceId]/events/route.ts
import { createServerSentEventResponse } from "@beignet/core/server";
import { authorizeWorkspaceEvents } from "@/features/collab/use-cases/authorize-workspace-events";
import { getServer } from "@/server";
export async function GET(
req: Request,
{ params }: { params: Promise<{ workspaceId: string }> },
) {
const { workspaceId } = await params;
const server = await getServer();
const handle = server
.rawRoute({
name: "workspaces.events",
method: "GET",
path: "/api/workspaces/:workspaceId/events",
})
.handle(async ({ req, ctx }) => {
await authorizeWorkspaceEvents.run({ ctx, input: { workspaceId } });
return createServerSentEventResponse({
signal: req.signal,
maxLifetimeMs: 240_000,
start({ send }) {
// Reconcile authoritative state on every initial connection/reconnect.
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 }),
});
});
return handle(req);
}With this route, a browser EventSource receives reconcile when it connects
and changed for each published message. Each MessageEvent.data value is the
JSON string produced from the corresponding data object.
The authorization use case and workspaceBroadcast port above are app-owned.
The helper does not provide authorization, replay, distributed connection
limits, or durable delivery. Treat ephemeral messages as invalidation hints:
keep the database authoritative and reconcile when the browser initially
connects or automatically reconnects. Set heartbeatMs: false only when the
host already owns keep-alives; maxLifetimeMs is disabled unless you opt in.
Both numeric timer options accept integers from 1 through 2147483647
milliseconds. Provide onError on production endpoints so subscription,
encoding, stream, and cleanup failures reach the application logger. Producer,
framing, and encoding failures close the connection; failures inside the error
observer are isolated from the stream lifecycle.
maxBufferedBytes limits the encoded frames waiting inside the response's Web
Stream and defaults to 1_048_576. If one frame or the accumulated unread
queue would exceed the limit, send(...) or comment(...) returns false, the
helper reports a RangeError through onError, and the connection closes so a
client can reconnect and reconcile. The start(...) callback receives a
stream-scoped signal; pass it to subscription APIs that perform asynchronous
setup. Response-body cancellation waits for cleanup that is already registered
without waiting indefinitely for pending setup. Cleanup returned after closure
still runs exactly once. An expected AbortError from setup after stream
closure is treated as cancellation and does not reach onError; other setup
rejections still report. Custom buffer limits accept integers from 1 through
2_147_483_647 bytes.
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.
Framework-neutral { status, headers, body } responses accept either a string
or a string array for each header. Use an array when the field must be emitted
more than once:
return {
status: 200,
headers: {
"set-cookie": [
"session=abc; Path=/; HttpOnly; Secure; SameSite=Lax",
"theme=dark; Path=/; Secure; SameSite=Lax",
],
},
body: { ok: true },
};The Web and Next adapters append each array item as a separate field, so
multiple cookies keep response validation, hooks, correlation headers, and
idempotency replay. A native Response remains the escape hatch for
transport-owned bodies and platform-specific behavior.
Document binary or streaming transport-owned routes with
.responses({ 200: null }) and an OpenAPI media override such as
application/octet-stream or text/event-stream; call them with platform
fetch when the caller needs bytes or a stream.
Response validation
Beignet automatically validates incoming requests against your contract
schemas. If validation fails, it returns a framework-owned 422 response whose
body identifies the contract, method, path, failing location (path, query,
headers, or body), and schema issues — see Errors
for the envelope. Your handler only runs if the request is valid.
It also parses outgoing handler responses against contract.responses and
sends the schema's parsed output. Object schemas that strip unknown keys keep
those keys off the wire, and schema transforms determine the value seen by
response finalizers, afterSend hooks, idempotency storage, and the HTTP
adapter. Typed clients parse the received response independently. The same
rule applies to catalog error-detail schemas.
If a handler returns an undeclared status or a body that does not match the
declared schema, Beignet returns a 500 contract-violation response instead of
silently drifting from the contract. Response violations identify the returned
status and the declared statuses, but never echo the invalid body, so handlers
cannot leak route-owned data while reporting drift. Pass
validateResponses: false to createServer(...) to skip this parse, mirroring
the typed client's validateResponses option. With validation disabled,
Beignet sends the handler body as-is; schema stripping and transforms do not
run.
Production posture. Response validation is on by default and costs one schema parse per response on your hottest routes. Keep it on in development, CI, and tests, where it catches contract drift the moment it happens. If profiling shows it is a measurable cost in production, drive it from env so only production trades the guarantee for throughput:
// lib/env.ts declares VALIDATE_RESPONSES as a boolean defaulting to true.
export const getServer = createNextServerLoader(() =>
createNextServer({
// ...
validateResponses: env.VALIDATE_RESPONSES,
}),
);Binder routes whose use case .output(...) schema is the same object as the
declared success response schema already skip the redundant success-status
parse, so for them this knob only affects error and undeclared statuses.
Validation responses are one kind of framework-owned response. For the full
route-owned / framework-owned / transport-owned taxonomy and the
x-beignet-error-owner header, see
Request lifecycle. For the app error
catalog, AppError, and unhandled-error mapping, see Errors.
Hooks
Server hooks wrap every request for protocol and lifecycle behavior; route
hooks attach beside contracts in feature route groups for auth, tenancy, and
feature-specific preconditions. See Hooks for lifecycle order,
createAuthHooks, and typing hook-enriched context with defineRoute.
Logging and Rate limiting are production
patterns built on hooks. Error reporting uses
createErrorReportingHooks(...) to capture unexpected HTTP failures without
changing response mapping.
Registration-time guarantees
createServer(...) validates the route registry up front so contract drift
fails at startup instead of surfacing as confusing request-time behavior:
- Unique method + path. Registering the same method and path twice throws,
and dynamic paths that differ only by parameter name (
/items/:idvs/items/:slug) are rejected as ambiguous. - Unique contract names. Typed clients, OpenAPI operations, and devtools key on contract names, so two contracts with the same name cannot both be registered even on different paths.
- Path templates match
pathParams. When a contract declares an introspectablepathParamsobject schema, its keys must match the:paramkeys in the path template; missing or extra keys throw at startup with the contract name and path. Non-introspectable Standard Schemas skip this check. - Body schemas require a body method. Request body schemas are supported
for
POST,PUT, andPATCHcontracts; attaching one toGET,HEAD,DELETE, orOPTIONSis rejected during registration.
These checks apply both to the routes list passed to createServer(...) and
to imperative server.route(contract).handle(...) registration. Runtime
dispatch — match specificity, 405 with Allow, and 404 — is covered in
Request lifecycle.
Structural registration checks run whenever a route is added. Startup hook
validate(...) methods and the server's contracts snapshot run once against
the initial routes list, however; they do not rerun for later imperative
server.route(...) registrations. Keep routes that must participate in
boot-time cross-route validation in the central registry, and list intentional
per-file contracts explicitly in OpenAPI or client catalogs.
Runtime integrity
Routes fail loudly when they are not registered. Workflow artifacts can fail more quietly: a listener, schedule, task, or outbox handler can exist in a feature folder but never make it into the runtime registry that executes it. Use runtime integrity when you want boot to catch that drift:
// server/runtime-integrity.ts
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 runtimeManifest = defineRuntimeManifest({
listeners: [...postListeners],
outbox: {
events: [...postEvents],
jobs: [...postJobs],
},
});
export const runtimeRegistries = defineRuntimeRegistries({
listeners,
outbox: outboxRegistry,
});
export const runtimeIntegrity = createRuntimeIntegrity({
manifest: runtimeManifest,
registries: runtimeRegistries,
});Then pass it to the server:
createNextServer({
ports,
context,
routes,
integrity: runtimeIntegrity,
});The check is pure and serverless-safe: it compares imported definitions and
registries in memory. It does not scan the filesystem, call providers, touch
databases or queues, start workers, or prove that cron and worker deployments
exist. mode: "warn" logs findings instead of failing startup; omitted
integrity leaves the server unchanged. beignet doctor --strict remains the
static drift check and also warns when an opted-in runtime manifest omits
feature workflow registries.