# @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

```bash
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.8 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

```typescript
// 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`.

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

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

Bind route declarations to that context once:

```typescript
// 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

```typescript
// 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.

```typescript
// 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

```typescript
// 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`:

```typescript
// 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:

```typescript
// 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.

```typescript
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`:

```typescript
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.

For native `Response` results, `beforeSend` receives a headers-only view and
can add or change headers without reading the body. `afterSend` receives 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.

```typescript
// 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.

Router-generated 404/405 responses skip request parsing and application context
creation. `onRequest`, `beforeSend`, and `afterSend` still run, including
correlation, CORS, and security headers; response hooks receive
`ctx: undefined`. Guard context access in custom hooks. A 405 includes `Allow`,
and a matched route returning an application 404 runs context and authorization
normally. Focused `server.route(contract).handle(...)` misses use the same
context-free response pipeline.

#### `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

```typescript
// 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>`.

```typescript
// 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:

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

```typescript
// 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:

```typescript
// 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.).

```typescript
await server.stop();
```

## Handler function context

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

```typescript
{
  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:

```typescript
// 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:

```typescript
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.

```typescript
// 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:

```typescript
// 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.

```typescript
// 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:

```typescript
// 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:

```json
{
  "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.

```typescript
// 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.

```typescript
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.

```typescript
// 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:

```http
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`:

```typescript
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`:

```typescript
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.

```typescript
// 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:

```typescript
// 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>`):

```typescript
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.

```typescript
// 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}`.

```typescript
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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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.):

```typescript
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

```typescript
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.

```typescript
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

```typescript
// 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 });
```

```typescript
// app-context.ts
export type AppContext = {
  todos: Array<{ id: string; title: string; completed: boolean }>;
};
```

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

```typescript
// 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 }) },
  ],
});
```

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

```typescript
// 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",
      },
    }),
  }),
);
```

```typescript
// 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

```typescript
// 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`:

```typescript
// 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

- [@beignet/core/server](https://beignetjs.com/server) - Framework-agnostic server runtime
- [@beignet/core/contracts](https://beignetjs.com/contracts) - Contract definitions
- [@beignet/core/openapi](https://beignetjs.com/openapi) - OpenAPI spec generation
- [@beignet/core/client](https://beignetjs.com/client) - Type-safe HTTP client

## 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.

```ts
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](https://beignetjs.com/broadcasting).

### 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`:

```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:

```ts
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
