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/next

v0.0.56

Published

Next.js server-side handlers for Beignet

Readme

@beignet/next

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

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

Next.js adapter for the framework-agnostic @beignet/core/server runtime. It builds on @beignet/web for standard Request/Response handling and adds Next-specific helpers for App Router handlers, Server Component context, OpenAPI routes, Swagger UI, uploads, payment webhooks, outbox drains, storage routes, and client base URLs.

Use @beignet/next for Next.js applications. Use @beignet/web directly when the runtime already accepts standard Web Fetch Request/Response objects and does not need Next-specific route helpers. Both adapters share the same framework-neutral core boundary: core owns route matching, hooks, validation, errors, response ownership, and provider lifecycle; adapters own platform request/response conversion.

Installation

npm install @beignet/next @beignet/core next

Beignet supports the maintained Next.js release lines: Next.js 15.5.24 or newer within v15, and Next.js 16.3.3 or newer within v16. These floors follow Next.js security releases; generated apps currently use Next.js 16.3.

Optional add-ons

  • @beignet/core/openapi for OpenAPI documentation
  • @beignet/core/ports if you want to define shared ports explicitly in your app

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/next#routes-server. Load it when wiring route groups, central route registration, createNextServer, server context, OpenAPI/devtools routes, Next catch-all API adapters, uploads, storage routes, webhooks, schedule cron routes, or outbox drain routes.

Quick start

1. Define your contracts

// features/todos/contracts.ts
import { defineContractGroup } from "@beignet/core/contracts";
import { z } from "zod";

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

export const getTodo = todos
  .get("/:id")
  .pathParams(z.object({ id: z.string() }))
  .responses({ 200: z.object({
    id: z.string(),
    title: z.string(),
    completed: z.boolean(),
  }) });

2. Define app context

This adapter quick start uses a tiny demo context. Production Beignet apps should use the canonical AppContext from the docs and generated starter: requestId, actor, auth, gate, ports, and optional requestInfo and tenant.

// app-context.ts
import type { TrustedRequestInfo } from "@beignet/core/server";

export type AppContext = {
  requestInfo: TrustedRequestInfo;
  userId: string;
};

Bind route declarations to that context once:

// 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>();

3. Wire feature routes

// features/todos/routes.ts
import { defineRouteGroup } from "@/lib/routes";
import { getTodo } from "@/features/todos/contracts";

export const todoRoutes = defineRouteGroup({
  name: "todos",
  routes: [
    {
      contract: getTodo,
      handle: async ({ path }) => ({
        status: 200,
        body: {
          id: path.id,
          title: "Example todo",
          completed: false,
        },
      }),
    },
  ],
});

For ordinary app routes, route entries live near the feature and bind a contract to a use case ({ contract, useCase }); the full { contract, handle } form shown above is the escape hatch for demo stubs and routes that own headers, streaming, or multi-status responses. Compose those groups once in server/routes.ts:

The default binder passes through one request schema or merges object inputs with path over body over query precedence. TypeScript rejects a default binding whose inferred input does not satisfy the use case input; add an explicit input: (parts) => ... mapper when the shapes differ or the use case reads validated headers.

// 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);

4. Create your server

// server/index.ts
import { createNextServer, createNextServerLoader } from "@beignet/next";
import type { AppContext } from "@/app-context";
import { routes } from "@/server/routes";

export const getServer = createNextServerLoader(() =>
  createNextServer<AppContext>({
    ports: {},
    routes,
    context: async ({ req, requestInfo }) => {
      // DEMO ONLY: this reads an unauthenticated header to simulate identity.
      // Real applications should verify a signed token or session cookie first.
      return {
        userId: req.headers.get("x-user-id") || "anonymous",
        requestInfo,
      };
    },
    mapUnhandledError: () => ({
      status: 500,
      body: {
        code: "INTERNAL_SERVER_ERROR",
        message: "Internal server error",
      },
    }),
  }),
);

Set trustedProxy on createNextServer(...) only when every request passes through an edge that strips or normalizes forwarded headers. The context factory and server hooks then receive the same resolved requestInfo with the external URL, origin, host, protocol, and optional client IP. Forwarding headers are ignored when the option is omitted.

5. Set up routes

Expose ordinary application routes through one catch-all framework route. Next App Router imports route modules during production builds, so keep server boot behind the memoized getServer loader and expose literal named exports with createApiRoute:

// 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);

This catch-all file is Next.js adapter glue. It forwards requests to the Beignet server, but individual contracts should still use explicit paths with single-segment params such as /posts/:id, not catch-all contract patterns such as /files/[...path].

Focused per-file routes

Use focused helpers or per-file server.route(contract).handle(...) handlers for endpoints that intentionally sit outside the central route registry, such as webhooks, redirects, downloads, or adapter-specific glue:

// app/api/webhooks/payments/route.ts
import { handlePaymentWebhookUseCase } from "@/features/billing/use-cases";
import { getServer } from "@/server";
import { createPaymentWebhookRoute } from "@beignet/next";

export const runtime = "nodejs";

export const { POST } = createPaymentWebhookRoute({
  server: getServer,
  handle: async ({ ctx, event }) => {
    await handlePaymentWebhookUseCase.run({ ctx, input: event });
    return { status: 200, body: { received: true } };
  },
});

Raw requests and non-JSON responses

@beignet/next passes handlers an HttpRequestLike with the underlying web Request at req.raw and its abort signal at req.signal. Use createWebhookRoute(...) for provider webhooks because it reads the raw request body, passes normalized headers into your verifier, and validates the typed event catalog before your app handles the event.

export const { POST } = createWebhookRoute({
  server: getServer,
  webhook: providerWebhook,
  handle: async ({ ctx, event }) => {
    await handleProviderEventUseCase.run({ ctx, input: event.payload });
    return { status: 200, body: { received: true } };
  },
});

For downloads, plain text, and redirects, return a native web Response:

import { getServer } from "@/server";

export async function GET(req: Request) {
  const server = await getServer();
  const handle = server.route(downloadFile).handle(async () =>
    new Response(await loadFile(), {
      headers: { "Content-Type": "application/octet-stream" },
    }),
  );

  return handle(req);
}

export async function POST(req: Request) {
  const server = await getServer();
  const handle = server.route(startCheckout).handle(async () =>
    Response.redirect("https://checkout.example.com/session/123", 303),
  );

  return handle(req);
}

Native Response instances intentionally bypass JSON serialization and response schema parsing. Use { status, body } when you want Beignet to parse and shape a JSON response through the contract schema; the schema's parsed output becomes the wire body. Use Response when you want full transport control. Because the Next adapter uses @beignet/web response conversion, an explicit ReadableStream in a plain Beignet response remains a stream for every media type, including application/json and structured +json types.

For Server-Sent Events, return createServerSentEventResponse(...) from @beignet/core/server. Pass req.signal so host-propagated disconnects clean up subscriptions; the helper also owns SSE framing, JSON encoding, heartbeat comments, optional maximum lifetime, a 1 MiB unread-data limit, cleanup, and anti-buffering headers. The start(...) callback receives a stream-scoped signal for cancellable asynchronous subscription setup. Expected AbortError rejections caused by stream closure are treated as cancellation rather than failures. Authentication, authorization, replay, connection limits, and client reconciliation remain application concerns.

Response-shaping hooks such as beforeSend only run for plain Beignet responses; observation hooks such as afterSend still receive the final status and headers.

API reference

createNextServer<Ctx>(options)

Creates a Next.js server instance with the given options.

Parameters:

  • options: Same as createServer from @beignet/core/server:
    • ports: Required - Ports object defining available service interfaces
    • context: Required - Context blueprint. Pass a plain request factory for gate-less contexts, or { gate, request, service } when the context type declares a gate. The server attaches ctx.gate itself; service powers server.createServiceContext(...)
    • mapUnhandledError: Error handler function
    • routes?: Array of route configurations (contract + handler)
    • hooks?: Optional ordered server hooks
    • trustedProxy?: Explicit policy for trusted edge-provided host, protocol, and client-IP metadata; forwarding headers are ignored when omitted
    • providers?: Optional array of service providers
    • providerEnv?: Optional environment variables for providers
    • providerConfig?: Optional provider configuration overrides

Returns: Promise<NextServer<Ctx>>

NextServer methods

createApiRoute(getServer)

A Next.js catch-all route helper for routes registered in server/index.ts. Framework-style apps usually expose it once 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);

This route file is not a catch-all contract. Keep contract paths explicit and use the file only to expose the central server handler.

Next App Router requires literal named exports for each HTTP method. The route helper keeps those exports literal while deferring provider startup until a request arrives.

server.route(contract)

Returns a route builder for focused per-file handlers such as webhooks, redirects, downloads, or other adapter-owned endpoints. Use defineRouteGroup({ ... }) plus defineRoutes(...) for ordinary application routes; server.route(contract).handle(...) route files are not imported by the central API handler.

Returns: Route builder with:

  • handle(fn): Create a custom handler function
// app/api/reports/[id]/download/route.ts
import { getServer } from "@/server";
import { downloadReport } from "@/features/reports/contracts";

export async function GET(req: Request) {
  const server = await getServer();
  const handle = server.route(downloadReport).handle(async ({ ctx, path }) =>
    new Response(await ctx.ports.reports.loadBytes(path.id), {
      headers: { "Content-Type": "application/pdf" },
    }),
  );

  return handle(req);
}

server.rawRoute(init)

Builds a handler for a route that cannot be a contract — third-party callback endpoints with externally defined request shapes, signature-verified webhooks, streaming endpoints — that still runs the whole server pipeline: correlation, hooks (rate limiting, idempotency, CORS, logging, error reporting), context creation, instrumentation, and framework error mapping. Request parsing and validation are skipped and the request body is left unconsumed, so the handler owns body reading.

init.name, init.method, and init.path identify the route to hooks, instrumentation, and devtools — routing itself belongs to the route file that mounts the handler. init.metadata feeds metadata-driven hooks exactly like contract metadata.

Returns: Builder with handle(fn) returning (req: Request) => Promise<Response>.

// 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();
      return { status: 200, body: { token: "..." } };
    });

  return roomAuth(req);
}

The webhook, payment webhook, schedule, and outbox drain route factories run through this pipeline automatically when the server they receive exposes rawRoute(...); their pipeline option supplies the route identity and metadata.

server.createContextFromNext()

Creates a context object from Next.js Server Components by automatically extracting headers and cookies. This allows you to call use cases directly from React Server Components without going through API routes.

Returns: Promise<Ctx> - Your fully assembled app context

For repeated Server Component and layout access, wrap it in a cached app helper:

// lib/server-context.ts
import "@beignet/core/server-only";

import { cache } from "react";
import { getServer } from "@/server";

export const getAppRequestContext = cache(async () => {
  const server = await getServer();
  return server.createContextFromNext();
});
// app/my-page/page.tsx
import { getTodoUseCase } from "@/features/todos/use-cases";
import { getAppRequestContext } from "@/lib/server-context";

export const dynamic = "force-dynamic";

export default async function MyPage() {
  const ctx = await getAppRequestContext();

  const todo = await getTodoUseCase.run({
    ctx,
    input: { id: "123" },
  });

  return <div>{todo.title}</div>;
}

This method:

  • Automatically calls Next.js's headers() and cookies() functions
  • Creates a minimal Request-like object with headers and cookies access. When headers() does not expose a standard cookie header, Beignet synthesizes one from cookies().getAll() so auth providers that read req.headers.get("cookie") behave the same way they do in API routes.
  • Delegates to server.createRequestContext(req) so the request context factory and gate attachment run exactly like API route handlers
  • Returns the same context type you get in API route handlers
  • Uses the HTTP method "GET" for the internal Request-like object. If your request context factory inspects req.method, it will always see "GET" when invoked via createContextFromNext().
  • The req.url is set to a placeholder (http://core/server-component.invalid) since Server Components don't have real HTTP URLs
  • The req.json() and req.text() methods return empty values since there's no actual HTTP request body in Server Components

Note: This method can only be called from Next.js Server Components (not in Client Components or during build time).

server.createRequestContext(req)

Builds a fully assembled request context from a framework-neutral HttpRequestLike. Use it for adapter entry points outside the route pipeline.

server.createServiceContext(input?)

Builds a service context through the service factory declared in the context blueprint. Use it for schedules, outbox drains, commands, and background work:

// server/schedules.ts
import { createServiceActor } from "@beignet/core/ports";
import { getServer } from "./index";

export async function createScheduleContext() {
  const server = await getServer();

  return server.createServiceContext({
    actor: createServiceActor("beignet-schedule"),
  });
}

Calling it without a declared context.service factory throws.

server.stop()

Stops the server and cleans up resources (closes provider connections, etc.).

await server.stop();

Handler function context

When using .handle(), your handler function receives an object with:

{
  req: HttpRequestLike,   // Framework-neutral request; native Request at req.raw
  ctx: Ctx,              // Your custom context from the context blueprint
  path: PathParams,      // Validated path parameters
  query: QueryParams,    // Validated query parameters
  body: Body,            // Validated request body
  contract: Contract,    // Resolved contract metadata and schemas
}

Use case integration

Beignet promotes clean architecture by separating use cases from HTTP concerns. Call use cases from handlers so the HTTP layer stays explicit:

// features/todos/use-cases.ts
export async function getTodoUseCase(
  input: { id: string },
  ports: AppPorts
) {
  return await ports.db.todos.findById(input.id);
}

// app/api/todos/[id]/route.ts
export const GET = server
  .route(getTodo)
  .handle(async ({ ctx, path }) => {
    const todo = await getTodoUseCase({ id: path.id }, ctx.ports);

    return { status: 200, body: todo };
  });

Hooks

Hooks can be added at the server level:

import { createNextServer, createNextServerLoader } from "@beignet/next";
import { createLoggingHooks } from "@beignet/core/server";

const logging = createLoggingHooks({
  logger: console,
  requestIdHeader: "x-request-id",
});

export const getServer = createNextServerLoader(() =>
  createNextServer({
    ports: {},
    hooks: [logging],
    context: async () => ({}),
    mapUnhandledError: () => ({
      status: 500,
      body: {
        code: "INTERNAL_SERVER_ERROR",
        message: "Internal server error",
      },
    }),
  }),
);

Runtime integrity

createNextServer(...) accepts the core integrity option. Pass a createRuntimeIntegrity(...) check from @beignet/core/server when a Next app should fail cold start if app-declared listeners, schedules, tasks, or outbox handlers are missing from the runtime registries. The check is pure and serverless-safe; it does not scan files, connect to providers, or start background work during route-module import.

OpenAPI documentation

If you have @beignet/core/openapi installed, use createOpenAPIHandler for a Next.js route. Pass explicit servers from app configuration for deployed docs; request-origin inference is available only when you opt in.

// app/api/openapi/route.ts
import { createOpenAPIHandler } from "@beignet/next";
import { env } from "@/lib/env";
import { contracts } from "@/server/routes";

export const GET = createOpenAPIHandler(contracts, {
  title: "My API",
  version: "1.0.0",
  servers: [{ url: env.APP_URL }],
});

Export contracts = contractsFromRoutes(routes) from server/routes.ts so the OpenAPI route can stay static and avoid booting providers during Next builds. If you export per-file Next handlers with server.route(contract).handle(...), keep an explicit contract list because those route files are not imported by the server automatically.

You can also serve Swagger UI without writing the HTML route by hand:

// app/api/docs/route.ts
import { createSwaggerUIHandler } from "@beignet/next";

export const GET = createSwaggerUIHandler({
  title: "My API Documentation",
  specUrl: "/api/openapi",
});

The built-in HTML loads version-pinned Swagger UI assets from unpkg.com with subresource-integrity checks. Static and request-derived specUrl values are serialized with HTML-safe JSON escaping before they enter the inline script. Apps with a strict Content Security Policy or no public CDN access should serve a custom, self-hosted documentation UI instead of this convenience handler. Using the built-in page requires policy allowances for the pinned asset host and its emitted inline script and style blocks; allowing unpkg.com alone is not sufficient. Protect the OpenAPI and documentation routes with app-owned authorization when enumerating the route surface is sensitive.

Public storage routes

Use createStorageRoute to serve public objects from a StoragePort in a Next.js App Router route. The route streams object bodies and maps missing objects, private objects, invalid keys, and paths outside basePath to 404.

// app/storage/[...key]/route.ts
import { createStorageRoute } from "@beignet/next";
import { getServer } from "@/server";

export const { GET, HEAD } = createStorageRoute(
  async () => (await getServer()).ports.storage,
  {
    basePath: "/storage",
  },
);

Served responses preserve object Content-Type, Cache-Control, Content-Length, and Last-Modified headers when available. They also set X-Content-Type-Options: nosniff. By default, active content types such as HTML, SVG, XML, and JavaScript are served with Content-Disposition: attachment; use contentDisposition: "inline" or a custom headers value only when the app intentionally serves active public assets from this route.

Upload routes

Use createUploadRoute to expose a Beignet upload router from a focused App Router route:

// app/api/uploads/[uploadName]/[action]/route.ts
import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
import { createUploadRouter } from "@beignet/core/uploads";
import { createUploadRoute } from "@beignet/next";
import { postUploads } from "@/features/posts/uploads";
import { getServer } from "@/server";

export const { POST } = createUploadRoute(async () => {
  const server = await getServer();

  return createUploadRouter({
    uploads: postUploads,
    ctx: () => server.createContextFromNext(),
    storage: server.ports.storage,
    instrumentation: resolveProviderInstrumentationPort(server.ports),
  });
});

The action segment must be prepare, upload, or complete.

Framework-owned operational failures from upload, webhook, payment-webhook, schedule, and outbox-drain helpers use the flat Beignet error body:

{
  "code": "WEBHOOK_VERIFICATION_FAILED",
  "message": "Webhook verification failed."
}

Schedule context such as scheduleName is placed under details. Success payloads keep their existing operation-specific shapes.

Webhook routes

Use createPaymentWebhookRoute(...) for billing flows backed by ctx.ports.payments. Use createWebhookRoute(...) for generic inbound webhooks backed by a defineWebhook(...) catalog and a provider verifier. The server option accepts any object exposing createRequestContext — a NextServer, a core ServerInstance, or a test fake — so server: getServer keeps working unchanged.

Full servers and minimal test fakes share one preflight boundary: generic webhooks read the bounded raw body first, payment webhooks require the signature header first, and neither resolves the server, runs hooks, or builds app context before those controls. When the server exposes rawRoute(...) — real Beignet servers do — requests that pass preflight then run inside the full hooks pipeline. Rate limiting, CORS, logging, error reporting, and instrumentation apply, the request appears in devtools, and the pipeline option supplies the route identity and metadata for metadata-driven hooks. The pipeline receives a rehydrated request while verification keeps the preflight raw-body text.

Both webhook helpers reject signed or generic raw bodies above 1 MiB with a 413 before server resolution, hooks, context creation, verification, or fulfillment. Payment webhooks check for the configured signature header first: an unsigned request returns 400 and its unread body is cancelled without being parsed. Set maxBodyBytes when the provider's documented payload limit requires a different bound.

// app/api/webhooks/github/route.ts
import { githubWebhook } from "@/features/integrations/webhooks";
import { handleGitHubWebhookUseCase } from "@/features/integrations/use-cases";
import { env } from "@/lib/env";
import { getServer } from "@/server";
import { createWebhookRoute } from "@beignet/next";
import { createGitHubWebhookVerifier } from "@beignet/webhooks-github";

export const runtime = "nodejs";

const githubWebhookVerifier = createGitHubWebhookVerifier({
  secret: () => env.GITHUB_WEBHOOK_SECRET,
});

export const { POST } = createWebhookRoute({
  server: getServer,
  webhook: githubWebhook,
  verify: ({ input }) => githubWebhookVerifier.verify(input),
  handle: async ({ ctx, event }) => {
    await handleGitHubWebhookUseCase.run({ ctx, input: event });
    return {
      status: 200,
      body: { received: true },
    };
  },
});

Oversized generic and signed payment bodies return 413. An unsigned payment request returns 400 and its unread body is cancelled. Other body read failures return 400 before context creation runs. Verification failures return 400 so providers do not treat an invalid signature as a fulfilled event. Context creation failures return 500. Handler failures return 500 so at-least-once webhook providers can retry.

Use provider verifiers such as createGitHubWebhookVerifier(...) from @beignet/webhooks-github or createStripeWebhookVerifier(...) from @beignet/webhooks-stripe in the route or server layer. Use the context-aware verify option when verification depends on app ports. Generic webhook routes reject verified unknown event types by default; set allowUnknownEvents: true only for broad provider endpoints that intentionally acknowledge valid event types the app does not handle.

export const { POST } = createWebhookRoute({
  server: getServer,
  webhook: githubWebhook,
  verify: ({ input }) => githubWebhookVerifier.verify(input),
  allowUnknownEvents: true,
  handle: async ({ event }) => {
    if (event.type !== "issues") {
      return { status: 200, body: { ignored: true } };
    }

    return { status: 200, body: { received: true } };
  },
});

createPaymentWebhookRoute(...) is the canonical shortcut for payment-port billing flows and the route generated by beignet make payments.

Outbox drain routes

Use createOutboxDrainRoute to expose durable outbox delivery from a cron or scheduled serverless route. The helper requires a bearer secret, builds app context from the real request with server.createRequestContext(...), drains one bounded batch with @beignet/core/outbox, records a drain summary into the instrumentation port resolved from ctx.ports (ports.instrumentation, then ports.devtools), passes request correlation fields to outbox instrumentation, and returns a JSON summary.

// app/api/cron/outbox/drain/route.ts
import { createOutboxDrainRoute } from "@beignet/next";
import { env } from "@/lib/env";
import { getServer } from "@/server";
import { outboxRegistry } from "@/server/outbox";

export const runtime = "nodejs";

export const { GET, POST } = createOutboxDrainRoute({
  server: getServer,
  registry: outboxRegistry,
  secret: env.CRON_SECRET,
});

The registry controls the route's delivery requirements. Registered events require ctx.ports.eventBus; registered jobs require ctx.ports.jobs. Declare those ports in AppPorts and bind them directly or defer them to a startup provider. Missing delivery capabilities fail before the drain claims messages, so a wiring error does not consume attempts.

The route uses the core drain's safe defaults: a batch of 100, serial delivery, a renewable 30-second lease, and a 5-minute maximum active duration. Set concurrency above one only when handlers can run without ordering guarantees. leaseMs, heartbeatMs, and maxActiveMs are available for app-owned tuning; keep the heartbeat shorter than the lease and synchronize worker clocks. Reaching maxActiveMs stops further renewals; the last confirmed lease can remain active until its own expiration.

Successful drains return { ok: true, result }. If claim ownership is lost or a delivery result cannot be settled durably, the route returns HTTP 500 with { ok: false, error, result }. Inspect result.leaseLost and result.settlementFailed; the message may be delivered again after lease expiry.

Export both GET and POST when you want the route to work with schedulers that call either method. Export only the method your scheduler uses when you want a narrower route surface.

Call the route from your scheduler with:

Authorization: Bearer <CRON_SECRET>

The bearer secret is checked with a timing-safe comparison, and a missing secret fails closed with a 500 response. Authentication runs before resolving the server or creating application context, so rejected cron traffic cannot initialize request-scoped dependencies.

Do not start long-running outbox polling loops from provider lifecycle hooks in serverless apps.

On Next.js 15.1 or newer, adapt after() to Beignet's non-durable BestEffortWorkPort in server/providers.ts:

import { createProvider } from "@beignet/core/providers";
import { createNextBestEffortWorkPort } from "@beignet/next";
import { after } from "next/server";
import type { AppPorts } from "@/ports";

export const bestEffortWorkProvider = createProvider<
  Pick<AppPorts, "logger">
>()({
  name: "next-best-effort-work",
  setup({ ports }) {
    return {
      ports: {
        bestEffortWork: createNextBestEffortWorkPort({
          defer: after,
          onError: (error) =>
            ports.logger.warn("Best-effort work failed", { error }),
        }),
      },
    };
  },
});

Declare bestEffortWork: BestEffortWorkPort in the app's AppPorts, defer that key in infra/port-wiring.ts, and register this provider after the logger provider. The adapter isolates synchronous and asynchronous scheduler errors, callback errors, and error-observer failures so they cannot change the originating response. This also means delivery is best effort: use it for dispensable work such as cache-invalidation hints, and use jobs or an outbox when delivery or retries are required. This binding is request-bound. Calling it from a worker, CLI command, or another context unsupported by Next's after() reports a scheduling failure through onError and does not run the callback.

On Next.js 15.1 or newer, combine the recovery route with push-assisted polling. createNextOutboxDrainTrigger(...) accepts after as an injected deferred-work scheduler, resolves service context and a lazy registry inside the callback, and performs one bounded drain. Keep the Next-specific wrapper in server/providers.ts, after the database provider that installs uow:

import { createObservedUnitOfWork } from "@beignet/core/ports";
import { createProvider } from "@beignet/core/providers";
import { createNextOutboxDrainTrigger } from "@beignet/next";
import { after } from "next/server";
import type { AppContext } from "@/app-context";
import type { AppPorts } from "@/ports";
import type { AppServiceContextInput } from "./context";

const outboxDrainProvider = createProvider<
  Pick<AppPorts, "uow">,
  AppContext,
  AppServiceContextInput
>()({
  name: "outbox-drain-trigger",
  setup({ ports, createServiceContext }): { ports: Pick<AppPorts, "uow"> } {
    const trigger: () => void = createNextOutboxDrainTrigger({
      defer: after,
      createContext: () => createServiceContext(undefined),
      registry: async () => (await import("./outbox")).outboxRegistry,
    });

    return {
      ports: {
        uow: createObservedUnitOfWork({
          unitOfWork: ports.uow,
          afterCommit: trigger,
        }),
      },
    };
  },
});

Register outboxDrainProvider after the provider that installs the database Unit of Work. Keeping the wrapper in server composition avoids an infra -> server dependency while the lazy registry import avoids the server/providers.ts and server/outbox.ts boot cycle.

This is a low-latency optimization, not durable execution. Keep the cron route as a recovery sweep, typically around every 15 minutes. Delayed messages, future retry timestamps, missed callbacks, and batches larger than the trigger limit depend on that sweep. Use a durable jobs provider when retries need a guaranteed low-latency wake-up. Older Next.js versions continue to use the cron-only route. Repeated triggers coalesce while a drain is scheduled or running, including triggers from transactions started by outbox handlers.

When ctx.ports.errorReporter is available, the route reports successfully dead-lettered messages, lease failures, settlement failures, and drain-level infrastructure failures. Scheduled retries remain in outbox instrumentation and do not create incident reports. Reporter failures do not change the drain result.

Schedule trigger routes

Use createScheduleRoute to trigger one registered schedule from a cron or scheduled serverless route. The helper requires a bearer secret, builds app context from the real request with server.createRequestContext(...), runs the schedule with the inline runner from @beignet/core/schedules, and records schedule events through the instrumentation port resolved from ctx.ports (ports.instrumentation, then ports.devtools) with the request's correlation fields.

// app/api/cron/digests/daily-digest/route.ts
import { createScheduleRoute } from "@beignet/next";
import { env } from "@/lib/env";
import { getServer } from "@/server";
import { schedules } from "@/server/schedules";

export const runtime = "nodejs";

export const { GET, POST } = createScheduleRoute({
  server: getServer,
  schedules,
  schedule: "digests.send-daily",
  secret: env.CRON_SECRET,
  source: "vercel-cron",
});

The schedule name is resolved when the route module loads, so unknown names throw at build or boot time instead of at the first cron invocation.

Authentication matches createOutboxDrainRoute: the bearer secret is checked with a timing-safe comparison, and a missing secret fails closed with a 500 response. The check runs before server resolution and context creation. Successful runs return { ok: true, scheduleName }; failed runs log through ctx.ports.logger, report once through ctx.ports.errorReporter when present, and return a 500 so schedule providers can retry. Reporter failures do not change the response.

source defaults to "next-cron-route" and is recorded on run metadata and devtools events.

Testing route files

The webhook, payment webhook, schedule, and outbox drain route factories build app context from the incoming request, not from next/headers, so route modules execute under a standard test runner with a plain Request. Import the route module directly and call the exported handler:

// app/api/webhooks/github/route.test.ts
import assert from "node:assert/strict";
import { it } from "node:test";
import { POST } from "./route";

it("rejects unsigned webhook deliveries", async () => {
  const response = await POST(
    new Request("http://localhost/api/webhooks/github", {
      method: "POST",
      body: JSON.stringify({ action: "opened" }),
    }),
  );

  assert.equal(response.status, 400);
});

To test a route factory against controlled context without booting the real server, pass any object exposing createRequestContext as the server option (NextRouteServer<Ctx>):

import assert from "node:assert/strict";
import { it } from "node:test";
import { createScheduleRoute } from "@beignet/next";
import { schedules } from "@/server/schedules";

it("triggers the daily digest schedule", async () => {
  const { POST } = createScheduleRoute({
    schedules,
    schedule: "digests.send-daily",
    secret: "test-secret",
    server: {
      async createRequestContext() {
        return createTestScheduleContext();
      },
    },
  });

  const response = await POST(
    new Request("http://localhost/api/cron/digests/daily-digest", {
      method: "POST",
      headers: { authorization: "Bearer test-secret" },
    }),
  );

  assert.equal(response.status, 200);
});

Client creation

Use createClient from @beignet/core/client for modules under client/ or Client Component import graphs. Browser calls default to same-origin relative URLs when no baseUrl is provided.

// client/index.ts
import { createClient } from "@beignet/core/client";

export const apiClient = createClient({
  headers: async () => ({}),
  validateInput: true,
});

The client gets endpoint types from the contract passed to apiClient.endpoint(contract). Next-friendly defaults supply same-origin browser URLs when no baseUrl is provided.

If server-side code must call HTTP instead of a use case or route handler directly, pass an absolute baseUrl to createClient(...) or use createNextClient outside client-root modules. createNextClient resolves server calls through NEXT_PUBLIC_API_URL, then VERCEL_URL, then http://localhost:${PORT || 3000}.

export const apiClient = createNextClient({
  serverBaseUrl: () => `http://localhost:${process.env.PORT || 3002}`,
});

For deployed apps, prefer setting NEXT_PUBLIC_API_URL when API calls should target a different origin.

App Router data flow

Beignet supports the normal App Router split:

  • Server Components can call use cases directly with a cached server.createContextFromNext() helper.
  • Server Components can prefetch contract queries and hydrate Client Components with TanStack Query.
  • Client Components use createClient() plus React Query options for interactive server state.
  • Route handlers stay thin and use createApiRoute(getServer) or focused helpers such as OpenAPI, uploads, storage, devtools, and outbox drains.

For repeated Server Component and layout access, define a cached app helper:

// lib/server-context.ts
import "@beignet/core/server-only";

import { cache } from "react";
import { getServer } from "@/server";

export const getAppRequestContext = cache(async () => {
  const server = await getServer();
  return server.createContextFromNext();
});

Layouts and Server Components can use that context for request metadata, ctx.auth, and ctx.tenant without turning those reads into HTTP calls. Keep feature data and business workflows behind use cases.

Server Component use-case calls

Use this when the page only needs server-rendered data and does not need a browser cache for the result:

// app/posts/[slug]/page.tsx
import { getAppRequestContext } from "@/lib/server-context";
import { getPostUseCase } from "@/features/posts/use-cases/get-post";

export const dynamic = "force-dynamic";

type PageProps = {
  params: Promise<{
    slug: string;
  }>;
};

export default async function Page({ params }: PageProps) {
  const { slug } = await params;
  const ctx = await getAppRequestContext();
  const post = await getPostUseCase.run({
    ctx,
    input: { slug },
  });

  return <article>{post.title}</article>;
}

Server prefetch plus hydration

Use this when the first render should be server-prefetched, but a Client Component should keep using TanStack Query for refetching, mutations, invalidation, and optimistic updates. For thin { contract, useCase } routes, keep the same contract-derived query key and replace only the server query function with an in-process use-case call:

// app/posts/[slug]/page.tsx
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { makeQueryClient, rq } from "@/client";
import { getPost } from "@/features/posts/contracts";
import { PostDetail } from "@/features/posts/components/post-detail";
import { getPostUseCase } from "@/features/posts/use-cases";
import { getAppRequestContext } from "@/lib/server-context";
import { serverUseCaseQueryOptions } from "@/lib/server-react-query";

type PageProps = {
  params: Promise<{
    slug: string;
  }>;
};

export default async function Page({ params }: PageProps) {
  const { slug } = await params;
  const ctx = await getAppRequestContext();
  const queryClient = makeQueryClient();

  await queryClient.prefetchQuery(
    serverUseCaseQueryOptions(
      rq(getPost).queryOptions({ path: { slug } }),
      getPostUseCase,
      ctx,
      { slug },
    ),
  );

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <PostDetail slug={slug} />
    </HydrationBoundary>
  );
}

params and searchParams are promises in modern App Router pages and route handlers. Await them before building contract path or query params.

Use this use-case path only when the route is a direct use-case binding and the use case output is the contract success body. If a route handler owns response mapping, headers, streaming, or other HTTP-layer behavior, prefetch through rq(contract).queryOptions(...) and use createNextClient(...) when server code needs an HTTP client with absolute base URL defaults.

Providers

Providers are service adapters that implement ports (database, cache, logger, etc.):

import { createNextServer, createNextServerLoader } from "@beignet/next";
import { createDrizzleSqliteProvider } from "@beignet/provider-db-drizzle/sqlite";
import { createPinoLoggerProvider } from "@beignet/provider-logger-pino";
import * as schema from "@/infra/db/schema";

const drizzleSqliteProvider = createDrizzleSqliteProvider({ schema });

export const getServer = createNextServerLoader(() =>
  createNextServer({
    ports: {},
    providers: [
      drizzleSqliteProvider,
      createPinoLoggerProvider(),
    ],
    providerEnv: process.env,
    context: async ({ ports }) => ({
      // Access providers via ports
      db: ports.db,
      logger: ports.logger,
    }),
    mapUnhandledError: () => ({
      status: 500,
      body: {
        code: "INTERNAL_SERVER_ERROR",
        message: "Internal server error",
      },
    }),
  }),
);

Error handling

Global error handler

import { createNextServer, createNextServerLoader } from "@beignet/next";

export const getServer = createNextServerLoader(() =>
  createNextServer({
    ports: {},
    context: async () => ({}),
    mapUnhandledError: ({ err }) => {
      console.error("Unhandled error:", err);
      return {
        status: 500,
        body: {
          code: "INTERNAL_SERVER_ERROR",
          message: "Internal server error",
        },
      };
    },
  }),
);

mapUnhandledError response bodies are sent to clients. Use onCaughtError for diagnostics, logging, and error reporting; keep response details limited to stable public fields that your app intentionally exposes.

Route-level error handling

Declare expected business failures on the contract with .errors(...), then throw your app's catalog helper from handlers or use cases.

import { appError } from "@/features/shared/errors";

export const GET = server
  .route(getTodo)
  .handle(async ({ ctx, path }) => {
    const todo = await fetchTodoById(path.id);

    if (!todo) {
      throw appError("TodoNotFound", { details: { id: path.id } });
    }

    return { status: 200, body: todo };
  });

Helper functions

createNextClient(config?): Client

Creates a @beignet/core/client instance with Next.js-friendly base URL defaults.

createNextBestEffortWorkPort(options): BestEffortWorkPort

Adapts Next.js 15.1 or newer's after() to Beignet's non-durable BestEffortWorkPort. Pass onError to observe scheduler or callback failures; the adapter isolates those failures and observer failures from the originating operation. Use jobs or an outbox for required or retryable delivery. The adapter is request-bound; calls outside a Next.js context supported by after() are passed to onError when configured.

resolveNextBaseUrl(config?): string

Resolves the base URL used by createNextClient.

createOpenAPIHandler(contracts, options): (req: Request) => Promise<Response>

Creates a Next.js route handler that returns an OpenAPI 3.1 JSON document. Requires @beignet/core/openapi in the app.

Pass servers for deployed docs. If servers is omitted, no OpenAPI servers entry is generated unless inferServersFromRequest: true is set.

When you use central route registration, export contractsFromRoutes(routes) from server/routes.ts so OpenAPI is generated from the same route list used by the runtime without booting providers during route-module import.

createSwaggerUIHandler(options?): (req: Request) => Response

Creates a Next.js route handler that serves Swagger UI for an OpenAPI endpoint. Its generated HTML loads version-pinned assets from unpkg.com with subresource-integrity checks, HTML-escapes the configured spec URL at the script boundary, and emits inline script and style blocks. Strict-CSP or network-isolated deployments should self-host a custom CSP-compatible UI. Allowing the asset host alone does not make the convenience handler compatible with a strict policy.

createWebhookRoute(options): { POST }

Creates a generic Next.js webhook route. The route reads the raw body, builds app context from the real request with server.createRequestContext(...), verifies the event through a webhook definition or context-aware verifier, validates the matching event payload schema, and delegates the event to your app-owned handler. Verified event types outside the webhook catalog fail with a 400 unless allowUnknownEvents: true is set. maxBodyBytes defaults to 1 MiB; larger bodies return 413 before server resolution, hooks, context creation, the verifier, or the handler.

createPaymentWebhookRoute(options): { POST }

Creates a Next.js payment webhook route. The route reads the raw body, builds app context from the real request with server.createRequestContext(...), verifies the provider signature through ctx.ports.payments.verifyWebhook(...), and delegates the normalized event to your app-owned handler. Prefer createWebhookRoute(...) for new non-payment webhook integrations. maxBodyBytes defaults to 1 MiB; larger bodies return 413 before server resolution, hooks, context creation, provider verification, or fulfillment when the configured signature header is present. Missing signatures take precedence, return 400, and cancel the unread body.

Operational helper error codes include UPLOAD_NOT_FOUND, INVALID_UPLOAD_ACTION, CRON_SECRET_NOT_CONFIGURED, UNAUTHORIZED, OUTBOX_DRAIN_FAILED, WEBHOOK_BODY_READ_FAILED, WEBHOOK_BODY_TOO_LARGE, WEBHOOK_CONTEXT_FAILED, WEBHOOK_VERIFICATION_FAILED, WEBHOOK_HANDLER_FAILED, PAYMENT_WEBHOOK_SIGNATURE_MISSING, PAYMENT_WEBHOOK_BODY_READ_FAILED, PAYMENT_WEBHOOK_BODY_TOO_LARGE, PAYMENT_WEBHOOK_CONTEXT_FAILED, PAYMENT_WEBHOOK_VERIFICATION_FAILED, PAYMENT_WEBHOOK_HANDLER_FAILED, and SCHEDULE_FAILED.

Web Fetch conversion helpers such as toRequestLike(...) and toWebResponse(...) belong to @beignet/web. The Next adapter uses them internally but does not re-export them. Framework-neutral response headers may use string arrays for repeated fields such as Set-Cookie; the adapter emits each item separately.

Examples

Basic CRUD API

// features/todos/contracts.ts
import { defineContractGroup } from "@beignet/core/contracts";
import { z } from "zod";

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

const todoSchema = z.object({
  id: z.string(),
  title: z.string(),
  completed: z.boolean(),
});

export const listTodos = todos
  .get("/")
  .responses({ 200: z.array(todoSchema) });

export const getTodo = todos
  .get("/:id")
  .pathParams(z.object({ id: z.string() }))
  .responses({ 200: todoSchema });

export const createTodo = todos
  .post("/")
  .body(z.object({ title: z.string() }))
  .responses({ 201: todoSchema });

export const updateTodo = todos
  .put("/:id")
  .pathParams(z.object({ id: z.string() }))
  .body(z.object({ title: z.string(), completed: z.boolean() }))
  .responses({ 200: todoSchema });

export const deleteTodo = todos
  .delete("/:id")
  .pathParams(z.object({ id: z.string() }))
  .responses({ 204: null });
// app-context.ts
export type AppContext = {
  todos: Array<{ id: string; title: string; completed: boolean }>;
};
// 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 * as todosContracts from "@/features/todos/contracts";

export const todoRoutes = defineRouteGroup({
  name: "todos",
  routes: [
    { contract: todosContracts.listTodos, handle: async () => ({ status: 200, body: [] }) },
    { contract: todosContracts.getTodo, handle: async ({ path }) => ({ status: 200, body: { id: path.id, title: "...", completed: false } }) },
    { contract: todosContracts.createTodo, handle: async ({ body }) => ({ status: 201, body: { id: "1", ...body, completed: false } }) },
    { contract: todosContracts.updateTodo, handle: async ({ path, body }) => ({ status: 200, body: { id: path.id, ...body } }) },
    { contract: todosContracts.deleteTodo, handle: async () => ({ status: 204 }) },
  ],
});
// 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);
// server/index.ts
import { createNextServer, createNextServerLoader } from "@beignet/next";
import type { AppContext } from "@/app-context";
import { routes } from "@/server/routes";

export const getServer = createNextServerLoader(() =>
  createNextServer<AppContext>({
    ports: {},
    routes,
    context: async () => ({ todos: [] }),
    mapUnhandledError: () => ({
      status: 500,
      body: {
        code: "INTERNAL_SERVER_ERROR",
        message: "Internal server error",
      },
    }),
  }),
);
// 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);

With authentication

// server/index.ts
import { createNextServer, createNextServerLoader } from "@beignet/next";
import { AuthUnauthorizedError } from "@beignet/core/ports";
import { getTodo } from "@/features/todos/contracts";

export const getServer = createNextServerLoader(() =>
  createNextServer({
    ports: {},
    context: async ({ req }) => {
      const user = await getUserFromRequest(req);

      if (!user) {
        throw new AuthUnauthorizedError();
      }

      return { user };
    },
    mapUnhandledError: () => {
      return {
        status: 500,
        body: {
          code: "INTERNAL_SERVER_ERROR",
          message: "Internal server error",
        },
      };
    },
  }),
);

Server component usage

You can call use cases directly from React Server Components using the cached request-context helper from lib/server-context.ts:

// app/todos/page.tsx
import { getAppRequestContext } from "@/lib/server-context";
import { listTodosUseCase } from "@/features/todos/use-cases";

export const dynamic = "force-dynamic";

export default async function TodosPage() {
  const ctx = await getAppRequestContext();

  const result = await listTodosUseCase.run({
    ctx,
    input: { limit: 10, offset: 0 },
  });

  return (
    <div>
      <h1>Todos</h1>
      <ul>
        {result.items.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </div>
  );
}

This approach:

  • Eliminates unnecessary API routes for server-side data fetching
  • Maintains type safety and business logic separation
  • Automatically handles headers and cookies from Next.js
  • Reuses the same cached request context across a Server Component render pass

Related packages

Typed broadcasting

createBroadcastRoute from @beignet/next exposes authorized channels over SSE using the server's context, metadata, route hooks, and HTTP error pipeline. Raw routes also accept hooks; fields added by those hooks are inferred in their handlers.

import { createBroadcastRoute } from "@beignet/next";
import { getServer } from "@/server";
import { channels } from "@/server/broadcasts";

export const runtime = "nodejs";
export const maxDuration = 120;
export const { GET } = createBroadcastRoute({
  server: getServer,
  channels,
  maxLifetimeMs: 60_000,
});

Register every channel with an explicit authorization binding, including public channels. Pass hooks: [auth.required()] when your app owns that hook; otherwise assert identity in the binding. server accepts an instance or a lazy loader. Options also include metadata, path (default /api/broadcasts), and resolveOrigin({ ctx, request }) for authenticated initiating-client exclusion. Mount GET at the configured path. maxLifetimeMs defaults to 60_000 and accepts positive safe integers from 1 through 3_600_000 (one hour). For a host permitting five-minute requests, use maxLifetimeMs: 240_000 with maxDuration = 300, leaving a minute for setup/cleanup. Longer streams reduce renewal and reconciliation frequency but increase the interval between authorization checks. Every new connection rechecks access.

The browser honors the advertised lifetime with a five-second watchdog grace period. Its deadline covers the whole connection; later readiness messages and heartbeats do not extend it. Heartbeat and initial-readiness timeouts stay independent. Every readiness message must include a valid maxLifetimeMs; missing or invalid lifetimes block subscriptions. Normal expiration sends a connection-wide renewal control frame before closing. Planned renewals still trigger onSync.

Global hook failures keep their HTTP status. Per-channel denials return sanitized control frames; temporary 429/5xx failures are retryable. The client must refetch after readiness/reconnection because broadcasts have no durable replay. It uses streaming Fetch, supports dynamic headers, and multiplexes up to 20 channels. Use Redis when publication jobs and stream handlers run in different processes.

For serverless hosts, allow streaming and outbound provider connections and set the host request deadline above the stream lifetime with setup/cleanup headroom. A Next.js Node route can use runtime = "nodejs" and maxDuration = 120 for a 60-second stream, subject to the host's limits. This does not imply Edge support for Node-specific providers. See the broadcasting guide.

Reserve a connection slot

The new optional admit({ ctx, request, signal }) hook runs once per physical multiplexed connection, after context/authentication hooks and before the SSE response. It may return a release function (synchronous or asynchronous), or nothing. Throw a catalog error to reject the whole HTTP request. HTTP 429 and 5xx responses are retryable; Retry-After is honored by the browser. Admission does not replace independent authorization for each channel.

This example uses the existing LocksPort to implement an application-owned three-slot policy. Declare locks: LocksPort in AppPorts and wire your lock provider in server/providers.ts. Memory locks limit one process; distributed limits need a shared, atomic lease store. Redis is one option, not a requirement. Add BroadcastConnectionLimit to features/shared/errors.ts with status 429, code BROADCAST_CONNECTION_LIMIT, and message Too many broadcast connections. Place the policy in server/broadcast-admission.ts:

import type { LocksPort } from "@beignet/core/locks";
import { appError } from "@/features/shared/errors";

export async function reserveBroadcastConnection({
  locks, userId, signal,
}: { locks: LocksPort; userId: string; signal: AbortSignal }) {
  for (let slot = 0; slot < 3; slot++) {
    signal.throwIfAborted();
    const result = await locks.acquire(`broadcast:${userId}:${slot}`, {
      ttlMs: 300_000,
      waitMs: 0,
    });
    if (result.acquired) {
      // Return ownership even if cancelled while awaiting acquisition.
      return async () => { await result.lease.release(); };
    }
  }
  throw appError("BroadcastConnectionLimit", { headers: { "Retry-After": "5" } });
}

Register it on the endpoint in app/api/broadcasts/route.ts. This example assumes the authenticated session is available in ctx.auth; requireUserId(ctx) asserts it before acquisition:

import { createBroadcastRoute } from "@beignet/next";
import { auth } from "@/lib/route-auth";
import { getServer } from "@/server";
import { requireUserId } from "@beignet/core/ports";
import { reserveBroadcastConnection } from "@/server/broadcast-admission";
import { channels } from "@/server/broadcasts";

export const runtime = "nodejs";
export const maxDuration = 300;
export const { GET } = createBroadcastRoute({
  server: getServer,
  channels,
  hooks: [auth.required()],
  maxLifetimeMs: 240_000,
  admit: ({ ctx, signal }) => reserveBroadcastConnection({
    locks: ctx.ports.locks,
    userId: requireUserId(ctx),
    signal,
  }),
});

For Fetch hosts, use the same admit option on createBroadcastRoute from @beignet/web in server/broadcast-route.ts, passing the assembled server and its channel registry. The host still owns its request duration setting.

Beignet calls each acquired release function once on expiration, request/body cancellation, provider closure, or failed stream setup. If only some channels fail, their resources are released while accepted channels retain the connection. If acquisition finishes after cancellation or the ten-second admission setup timeout, its returned resource is released immediately. Pass signal to an acquisition API when supported; the example checks it between attempts, then returns a successful acquisition even if cancellation happened while awaiting it. Throwing after acquiring without returning release would leak that resource.

Cleanup failures emit broadcast.cleanup-error without skipping other releases. No application stream event listeners are needed. Each renewal runs fresh admission and authorization. Distributed leases still need expiry because abrupt process termination cannot run cleanup. Keep lease TTL above the stream lifetime plus acquisition/setup/cleanup headroom; this example pairs a five-minute lease with a four-minute stream. The hosting request limit must also leave that headroom. Storage, slot count, expiry, and rejection policy remain application-owned; the framework imposes no connection-limit store.

License

MIT