@beignet/next 0.0.1 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,26 @@
1
1
  # @beignet/next
2
2
 
3
- Next.js adapter for the framework-agnostic `@beignet/core/server` runtime. It translates Next's `Request`/`Response` to/from the runtime's `HttpRequestLike`/`HttpResponseLike` shapes and provides small helpers for App Router handlers, OpenAPI routes, Swagger UI, and client base URLs.
3
+ > [!CAUTION]
4
+ > Beignet is experimental alpha software. The `0.0.x` package line is for early
5
+ > evaluation, and APIs may change between releases while the framework settles.
6
+
7
+ Next.js adapter for the framework-agnostic `@beignet/core/server` runtime. It
8
+ builds on `@beignet/web` for standard `Request`/`Response` handling and adds
9
+ Next-specific helpers for App Router handlers, Server Component context,
10
+ OpenAPI routes, Swagger UI, uploads, outbox drains, storage routes, and client
11
+ base URLs.
12
+
13
+ Use `@beignet/next` for Next.js applications. Use `@beignet/web` directly when
14
+ the runtime already accepts standard Web Fetch `Request`/`Response` objects and
15
+ does not need Next-specific route helpers. Both adapters share the same
16
+ framework-neutral core boundary: core owns route matching, hooks, validation,
17
+ errors, response ownership, and provider lifecycle; adapters own platform
18
+ request/response conversion.
4
19
 
5
20
  ## Installation
6
21
 
7
22
  ```bash
8
- npm install @beignet/next next
23
+ npm install @beignet/next @beignet/core next
9
24
  ```
10
25
 
11
26
  ### Optional add-ons
@@ -23,10 +38,10 @@ This package requires TypeScript 5.0 or higher for proper type inference.
23
38
 
24
39
  ```typescript
25
40
  // features/todos/contracts.ts
26
- import { createContractGroup } from "@beignet/core/contracts";
41
+ import { defineContractGroup } from "@beignet/core/contracts";
27
42
  import { z } from "zod";
28
43
 
29
- const todos = createContractGroup()
44
+ const todos = defineContractGroup()
30
45
  .namespace("todos")
31
46
  .prefix("/api/todos");
32
47
 
@@ -40,20 +55,30 @@ export const getTodo = todos
40
55
  }) });
41
56
  ```
42
57
 
43
- ### 2. Create your server
58
+ ### 2. Define app context
59
+
60
+ This adapter quick start uses a tiny demo context. Production Beignet apps
61
+ should use the canonical `AppContext` from the docs and generated starter:
62
+ `requestId`, `actor`, `auth`, `gate`, `ports`, and optional `tenant`.
44
63
 
45
64
  ```typescript
46
- // server/index.ts
47
- import {
48
- createNextServer,
49
- defineRouteGroup,
50
- defineRoutes,
51
- } from "@beignet/next";
65
+ // app-context.ts
66
+ export type AppContext = {
67
+ userId: string;
68
+ };
69
+ ```
70
+
71
+ ### 3. Wire feature routes
72
+
73
+ ```typescript
74
+ // features/todos/routes.ts
75
+ import { defineRouteGroup } from "@beignet/next";
76
+ import type { AppContext } from "@/app-context";
52
77
  import { getTodo } from "@/features/todos/contracts";
53
78
 
54
- export const server = await createNextServer({
55
- ports: {},
56
- routes: defineRoutes([
79
+ export const todoRoutes = defineRouteGroup<AppContext>({
80
+ name: "todos",
81
+ routes: [
57
82
  {
58
83
  contract: getTodo,
59
84
  handle: async ({ path }) => ({
@@ -65,57 +90,59 @@ export const server = await createNextServer({
65
90
  },
66
91
  }),
67
92
  },
68
- ]),
69
- createContext: async ({ req }) => {
93
+ ],
94
+ });
95
+ ```
96
+
97
+ For ordinary app routes, route entries live near the feature and bind a
98
+ contract to a use case (`{ contract, useCase }`); the full
99
+ `{ contract, handle }` form shown above is the escape hatch for demo stubs and
100
+ routes that own headers, streaming, or multi-status responses. Compose those
101
+ groups once in `server/routes.ts`:
102
+
103
+ ```typescript
104
+ // server/routes.ts
105
+ import { contractsFromRoutes, defineRoutes } from "@beignet/next";
106
+ import type { AppContext } from "@/app-context";
107
+ import { todoRoutes } from "@/features/todos/routes";
108
+
109
+ export const routes = defineRoutes<AppContext>([todoRoutes]);
110
+ export const contracts = contractsFromRoutes(routes);
111
+ ```
112
+
113
+ ### 4. Create your server
114
+
115
+ ```typescript
116
+ // server/index.ts
117
+ import { createNextServer } from "@beignet/next";
118
+ import type { AppContext } from "@/app-context";
119
+ import { routes } from "@/server/routes";
120
+
121
+ export const server = await createNextServer<AppContext>({
122
+ ports: {},
123
+ routes,
124
+ context: async ({ req }) => {
70
125
  // DEMO ONLY: this reads an unauthenticated header to simulate identity.
71
126
  // Real applications should verify a signed token or session cookie first.
72
127
  return {
73
128
  userId: req.headers.get("x-user-id") || "anonymous",
74
129
  };
75
130
  },
76
- mapUnhandledError: ({ err }) => ({
131
+ mapUnhandledError: () => ({
77
132
  status: 500,
78
133
  body: {
79
134
  code: "INTERNAL_SERVER_ERROR",
80
135
  message: "Internal server error",
81
- ...(err instanceof Error ? { details: { message: err.message } } : {}),
82
136
  },
83
137
  }),
84
138
  });
85
139
  ```
86
140
 
87
- For larger apps, group related handlers near the feature and compose them with
88
- `defineRoutes`:
89
-
90
- ```typescript
91
- const todoRoutes = defineRouteGroup({
92
- name: "todos",
93
- routes: [
94
- {
95
- contract: getTodo,
96
- handle: async ({ path }) => ({
97
- status: 200,
98
- body: {
99
- id: path.id,
100
- title: "Example todo",
101
- completed: false,
102
- },
103
- }),
104
- },
105
- ],
106
- });
107
-
108
- export const routes = defineRoutes([todoRoutes]);
109
- ```
141
+ ### 5. Set up routes
110
142
 
111
- ### 3. Set up routes
112
-
113
- You have two options for routing:
114
-
115
- #### Option A: catch-all framework route (recommended)
116
-
117
- Register handlers centrally in `server/index.ts`, then expose the central
118
- handler from one catch-all Next.js route file:
143
+ Expose ordinary application routes through one catch-all framework route. Next
144
+ App Router requires literal named exports for each HTTP method, so assign the
145
+ central `server.api` handler to every verb your API should accept:
119
146
 
120
147
  ```typescript
121
148
  // app/api/[[...path]]/route.ts
@@ -130,24 +157,31 @@ export const POST = server.api;
130
157
  export const PUT = server.api;
131
158
  ```
132
159
 
133
- #### Option B: per-contract routes
160
+ This catch-all file is Next.js adapter glue. It forwards requests to the
161
+ Beignet server, but individual contracts should still use explicit paths with
162
+ single-segment params such as `/posts/:id`, not catch-all contract patterns
163
+ such as `/files/[...path]`.
134
164
 
135
- Create individual route files for each contract:
165
+ #### Focused per-file routes
166
+
167
+ Use per-file `server.route(contract).handle(...)` handlers for endpoints that
168
+ intentionally sit outside the central route registry, such as webhooks,
169
+ redirects, downloads, or adapter-specific glue:
136
170
 
137
171
  ```typescript
138
- // app/api/todos/[id]/route.ts
172
+ // app/api/webhooks/stripe/route.ts
139
173
  import { server } from "@/server";
140
- import { getTodo } from "@/features/todos/contracts";
174
+ import { stripeWebhook } from "@/features/billing/contracts";
141
175
 
142
- export const GET = server
143
- .route(getTodo)
144
- .handle(async ({ ctx, path }) => {
145
- // Implement your handler logic
146
- const todo = await fetchTodoById(path.id);
147
- return {
148
- status: 200,
149
- body: todo,
150
- };
176
+ export const POST = server
177
+ .route(stripeWebhook)
178
+ .handle(async ({ req }) => {
179
+ const rawBody = await req.text();
180
+ const signature = req.headers.get("stripe-signature");
181
+
182
+ verifyWebhookSignature(rawBody, signature);
183
+
184
+ return { status: 200, body: { received: true } };
151
185
  });
152
186
  ```
153
187
 
@@ -204,7 +238,10 @@ Creates a Next.js server instance with the given options.
204
238
  **Parameters:**
205
239
  - `options`: Same as `createServer` from `@beignet/core/server`:
206
240
  - `ports`: **Required** - Ports object defining available service interfaces
207
- - `createContext`: Async function to create request context
241
+ - `context`: **Required** - Context blueprint. Pass a plain request factory
242
+ for gate-less contexts, or `{ gate, request, service }` when the context
243
+ type declares a `gate`. The server attaches `ctx.gate` itself; `service`
244
+ powers `server.createServiceContext(...)`
208
245
  - `mapUnhandledError`: Error handler function
209
246
  - `routes?`: Array of route configurations (contract + handler)
210
247
  - `hooks?`: Optional ordered server hooks
@@ -234,45 +271,42 @@ export const POST = server.api;
234
271
  export const PUT = server.api;
235
272
  ```
236
273
 
274
+ This route file is not a catch-all contract. Keep contract paths explicit and
275
+ use the file only to expose the central server handler.
276
+
277
+ Next App Router requires literal named exports for each HTTP method, so assign
278
+ the same `server.api` handler to every verb your API should accept.
279
+
237
280
  #### `server.route(contract)`
238
281
 
239
- Returns a route builder for creating custom handlers for a specific contract. The contract is registered globally and available via `server.api`.
282
+ Returns a route builder for focused per-file handlers such as webhooks,
283
+ redirects, downloads, or other adapter-owned endpoints. Use
284
+ `defineRouteGroup(...)` plus `defineRoutes(...)` for ordinary application
285
+ routes; `server.route(contract).handle(...)` route files are not imported by
286
+ the central `server.api` handler.
240
287
 
241
288
  **Returns:** Route builder with:
242
289
  - `handle(fn)`: Create a custom handler function
243
290
 
244
291
  ```typescript
245
- // app/api/todos/[id]/route.ts
292
+ // app/api/reports/[id]/download/route.ts
246
293
  import { server } from "@/server";
247
- import { getTodo } from "@/features/todos/contracts";
248
- import { getTodoUseCase } from "@/features/todos/use-cases/get-todo";
249
-
250
- // Option 1: Custom handler
251
- export const GET = server
252
- .route(getTodo)
253
- .handle(async ({ req, ctx, path, query, body }) => {
254
- // Your implementation
255
- return { status: 200, body: { id: path.id, title: "..." } };
256
- });
294
+ import { downloadReport } from "@/features/reports/contracts";
257
295
 
258
- // Option 2: Call a use case inside the handler
259
296
  export const GET = server
260
- .route(getTodo)
261
- .handle(async ({ ctx, path }) => {
262
- const todo = await getTodoUseCase.run({
263
- ctx,
264
- input: { id: path.id },
265
- });
266
-
267
- return { status: 200, body: todo };
268
- });
297
+ .route(downloadReport)
298
+ .handle(async ({ ctx, path }) =>
299
+ new Response(await ctx.ports.reports.loadBytes(path.id), {
300
+ headers: { "Content-Type": "application/pdf" },
301
+ }),
302
+ );
269
303
  ```
270
304
 
271
305
  #### `server.createContextFromNext()`
272
306
 
273
307
  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.
274
308
 
275
- **Returns:** `Promise<Ctx>` - Your context object from `createContext`
309
+ **Returns:** `Promise<Ctx>` - Your fully assembled app context
276
310
 
277
311
  ```typescript
278
312
  // app/my-page/page.tsx
@@ -296,14 +330,40 @@ export default async function MyPage() {
296
330
  This method:
297
331
  - Automatically calls Next.js's `headers()` and `cookies()` functions
298
332
  - Creates a minimal Request-like object with headers and cookies access
299
- - Calls your `createContext` function with this request object
333
+ - Delegates to `server.createRequestContext(req)` so the request context
334
+ factory and gate attachment run exactly like API route handlers
300
335
  - Returns the same context type you get in API route handlers
301
- - Uses the HTTP method `"GET"` for the internal Request-like object. If your `createContext` implementation inspects `req.method`, it will always see `"GET"` when invoked via `createContextFromNext()`.
336
+ - 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()`.
302
337
  - The `req.url` is set to a placeholder (`http://core/server-component.invalid`) since Server Components don't have real HTTP URLs
303
338
  - The `req.json()` and `req.text()` methods return empty values since there's no actual HTTP request body in Server Components
304
339
 
305
340
  **Note:** This method can only be called from Next.js Server Components (not in Client Components or during build time).
306
341
 
342
+ #### `server.createRequestContext(req)`
343
+
344
+ Builds a fully assembled request context from a framework-neutral
345
+ `HttpRequestLike`. Use it for adapter entry points outside the route pipeline.
346
+
347
+ #### `server.createServiceContext(input?)`
348
+
349
+ Builds a service context through the `service` factory declared in the
350
+ `context` blueprint. Use it for schedules, outbox drains, commands, and
351
+ background work:
352
+
353
+ ```typescript
354
+ // server/schedules.ts
355
+ import { createServiceActor } from "@beignet/core/ports";
356
+ import { server } from "./index";
357
+
358
+ export async function createScheduleContext() {
359
+ return server.createServiceContext({
360
+ actor: createServiceActor("beignet-schedule"),
361
+ });
362
+ }
363
+ ```
364
+
365
+ Calling it without a declared `context.service` factory throws.
366
+
307
367
  #### `server.stop()`
308
368
 
309
369
  Stops the server and cleans up resources (closes provider connections, etc.).
@@ -319,7 +379,7 @@ When using `.handle()`, your handler function receives an object with:
319
379
  ```typescript
320
380
  {
321
381
  req: HttpRequestLike, // Raw request object
322
- ctx: Ctx, // Your custom context from createContext
382
+ ctx: Ctx, // Your custom context from the context blueprint
323
383
  path: PathParams, // Validated path parameters
324
384
  query: QueryParams, // Validated query parameters
325
385
  body: Body, // Validated request body
@@ -366,7 +426,7 @@ const logging = createLoggingHooks({
366
426
  export const server = await createNextServer({
367
427
  ports: {},
368
428
  hooks: [logging],
369
- createContext: async () => ({}),
429
+ context: async () => ({}),
370
430
  mapUnhandledError: () => ({
371
431
  status: 500,
372
432
  body: {
@@ -429,20 +489,137 @@ export const { GET, HEAD } = createStorageRoute(server.ports.storage, {
429
489
  Served responses preserve object `Content-Type`, `Cache-Control`,
430
490
  `Content-Length`, and `Last-Modified` headers when available.
431
491
 
492
+ ## Upload routes
493
+
494
+ Use `createUploadRoute` to expose a Beignet upload router from a focused App
495
+ Router route:
496
+
497
+ ```typescript
498
+ // app/api/uploads/[uploadName]/[action]/route.ts
499
+ import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
500
+ import { createUploadRouter } from "@beignet/core/uploads";
501
+ import { createUploadRoute } from "@beignet/next";
502
+ import { postUploads } from "@/features/posts/uploads";
503
+ import { server } from "@/server";
504
+
505
+ const uploadRouter = createUploadRouter({
506
+ uploads: postUploads,
507
+ ctx: () => server.createContextFromNext(),
508
+ storage: server.ports.storage,
509
+ instrumentation: resolveProviderInstrumentationPort(server.ports),
510
+ });
511
+
512
+ export const { POST } = createUploadRoute(uploadRouter);
513
+ ```
514
+
515
+ The `action` segment must be `prepare`, `upload`, or `complete`.
516
+
517
+ ## Outbox drain routes
518
+
519
+ Use `createOutboxDrainRoute` to expose durable outbox delivery from a cron or
520
+ scheduled serverless route. The helper requires a bearer secret, drains one
521
+ bounded batch with `@beignet/core/outbox`, records a drain summary into the
522
+ instrumentation port resolved from `ctx.ports` (`ports.instrumentation`, then
523
+ `ports.devtools`), passes request correlation fields to outbox
524
+ instrumentation, and returns a JSON summary.
525
+
526
+ ```typescript
527
+ // app/api/cron/outbox/drain/route.ts
528
+ import { createOutboxDrainRoute } from "@beignet/next";
529
+ import { env } from "@/lib/env";
530
+ import { server } from "@/server";
531
+ import { outboxRegistry } from "@/server/outbox";
532
+
533
+ export const runtime = "nodejs";
534
+
535
+ export const { GET, POST } = createOutboxDrainRoute({
536
+ server,
537
+ registry: outboxRegistry,
538
+ secret: env.CRON_SECRET,
539
+ batchSize: 100,
540
+ });
541
+ ```
542
+
543
+ Export both `GET` and `POST` when you want the route to work with schedulers
544
+ that call either method. Export only the method your scheduler uses when you
545
+ want a narrower route surface.
546
+
547
+ Call the route from your scheduler with:
548
+
549
+ ```http
550
+ Authorization: Bearer <CRON_SECRET>
551
+ ```
552
+
553
+ The bearer secret is checked with a timing-safe comparison, and a missing
554
+ secret fails closed with a 500 response.
555
+
556
+ Do not start long-running outbox polling loops from provider lifecycle hooks in
557
+ serverless apps.
558
+
559
+ ## Schedule trigger routes
560
+
561
+ Use `createScheduleRoute` to trigger one registered schedule from a cron or
562
+ scheduled serverless route. The helper requires a bearer secret, creates app
563
+ context with `server.createContextFromNext()`, runs the schedule with the
564
+ inline runner from `@beignet/core/schedules`, and records `schedule` events
565
+ through the instrumentation port resolved from `ctx.ports`
566
+ (`ports.instrumentation`, then `ports.devtools`) with the request's
567
+ correlation fields.
568
+
569
+ ```typescript
570
+ // app/api/cron/digests/daily-digest/route.ts
571
+ import { createScheduleRoute } from "@beignet/next";
572
+ import { env } from "@/lib/env";
573
+ import { server } from "@/server";
574
+ import { schedules } from "@/server/schedules";
575
+
576
+ export const runtime = "nodejs";
577
+
578
+ export const { GET, POST } = createScheduleRoute({
579
+ server,
580
+ schedules,
581
+ schedule: "digests.send-daily",
582
+ secret: env.CRON_SECRET,
583
+ source: "vercel-cron",
584
+ });
585
+ ```
586
+
587
+ The schedule name is resolved when the route module loads, so unknown names
588
+ throw at build or boot time instead of at the first cron invocation.
589
+
590
+ Authentication matches `createOutboxDrainRoute`: the bearer secret is checked
591
+ with a timing-safe comparison, and a missing secret fails closed with a 500
592
+ response. Successful runs return `{ ok: true, scheduleName }`; failed runs log
593
+ through `ctx.ports.logger` and return a 500 so schedule providers can retry.
594
+
595
+ `source` defaults to `"next-cron-route"` and is recorded on run metadata and
596
+ devtools events.
597
+
432
598
  ## Client creation
433
599
 
434
- Use `createNextClient` when a client may run in both browser and server environments. Browser calls default to same-origin relative URLs. Server calls use `NEXT_PUBLIC_API_URL`, then `VERCEL_URL`, then `http://localhost:${PORT || 3000}`.
600
+ Use `createClient` from `@beignet/core/client` for modules under `client/` or
601
+ Client Component import graphs. Browser calls default to same-origin relative
602
+ URLs when no `baseUrl` is provided.
435
603
 
436
604
  ```typescript
437
- // client/api-client.ts
438
- import { createNextClient } from "@beignet/next";
605
+ // client/index.ts
606
+ import { createClient } from "@beignet/core/client";
439
607
 
440
- export const apiClient = createNextClient({
608
+ export const apiClient = createClient({
441
609
  headers: async () => ({}),
610
+ validateInput: true,
442
611
  });
443
612
  ```
444
613
 
445
- If your local app runs on a non-default port, provide a server-only fallback:
614
+ The client gets endpoint types from the contract passed to
615
+ `apiClient.endpoint(contract)`. Next-friendly defaults supply same-origin
616
+ browser URLs when no `baseUrl` is provided.
617
+
618
+ If server-side code must call HTTP instead of a use case or route handler
619
+ directly, pass an absolute `baseUrl` to `createClient(...)` or use
620
+ `createNextClient` outside client-root modules. `createNextClient` resolves
621
+ server calls through `NEXT_PUBLIC_API_URL`, then `VERCEL_URL`, then
622
+ `http://localhost:${PORT || 3000}`.
446
623
 
447
624
  ```typescript
448
625
  export const apiClient = createNextClient({
@@ -452,6 +629,83 @@ export const apiClient = createNextClient({
452
629
 
453
630
  For deployed apps, prefer setting `NEXT_PUBLIC_API_URL` when API calls should target a different origin.
454
631
 
632
+ ## App Router data flow
633
+
634
+ Beignet supports the normal App Router split:
635
+
636
+ - Server Components can call use cases directly with `server.createContextFromNext()`.
637
+ - Server Components can prefetch contract queries and hydrate Client Components with TanStack Query.
638
+ - Client Components use `createClient()` plus React Query options for interactive server state.
639
+ - Route handlers stay thin and expose `server.api` or focused helpers such as OpenAPI, uploads, storage, devtools, and outbox drains.
640
+
641
+ ### Server Component use-case calls
642
+
643
+ Use this when the page only needs server-rendered data and does not need a
644
+ browser cache for the result:
645
+
646
+ ```typescript
647
+ // app/posts/[slug]/page.tsx
648
+ import { server } from "@/server";
649
+ import { getPostUseCase } from "@/features/posts/use-cases/get-post";
650
+
651
+ type PageProps = {
652
+ params: Promise<{
653
+ slug: string;
654
+ }>;
655
+ };
656
+
657
+ export default async function Page({ params }: PageProps) {
658
+ const { slug } = await params;
659
+ const ctx = await server.createContextFromNext();
660
+ const post = await getPostUseCase.run({
661
+ ctx,
662
+ input: { slug },
663
+ });
664
+
665
+ return <article>{post.title}</article>;
666
+ }
667
+ ```
668
+
669
+ ### Server prefetch plus hydration
670
+
671
+ Use this when the first render should be server-prefetched, but a Client
672
+ Component should keep using TanStack Query for refetching, mutations,
673
+ invalidation, and optimistic updates:
674
+
675
+ ```typescript
676
+ // app/posts/[slug]/page.tsx
677
+ import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
678
+ import { makeQueryClient, rq } from "@/client";
679
+ import { getPost } from "@/features/posts/contracts";
680
+ import { PostDetail } from "@/features/posts/components/post-detail";
681
+
682
+ type PageProps = {
683
+ params: Promise<{
684
+ slug: string;
685
+ }>;
686
+ };
687
+
688
+ export default async function Page({ params }: PageProps) {
689
+ const { slug } = await params;
690
+ const queryClient = makeQueryClient();
691
+
692
+ await queryClient.prefetchQuery(
693
+ rq(getPost).queryOptions({
694
+ path: { slug },
695
+ }),
696
+ );
697
+
698
+ return (
699
+ <HydrationBoundary state={dehydrate(queryClient)}>
700
+ <PostDetail slug={slug} />
701
+ </HydrationBoundary>
702
+ );
703
+ }
704
+ ```
705
+
706
+ `params` and `searchParams` are promises in modern App Router pages and route
707
+ handlers. Await them before building contract path or query params.
708
+
455
709
  ## Providers
456
710
 
457
711
  Providers are service adapters that implement ports (database, cache, logger, etc.):
@@ -471,7 +725,7 @@ export const server = await createNextServer({
471
725
  loggerPinoProvider,
472
726
  ],
473
727
  providerEnv: process.env,
474
- createContext: async ({ ports }) => ({
728
+ context: async ({ ports }) => ({
475
729
  // Access providers via ports
476
730
  db: ports.db,
477
731
  logger: ports.logger,
@@ -493,23 +747,24 @@ export const server = await createNextServer({
493
747
  ```typescript
494
748
  export const server = await createNextServer({
495
749
  ports: {},
496
- createContext: async () => ({}),
497
- mapUnhandledError: ({ err, ctx }) => {
750
+ context: async () => ({}),
751
+ mapUnhandledError: ({ err }) => {
498
752
  console.error("Unhandled error:", err);
499
753
  return {
500
754
  status: 500,
501
755
  body: {
502
756
  code: "INTERNAL_SERVER_ERROR",
503
757
  message: "Internal server error",
504
- ...(process.env.NODE_ENV === "development" && err instanceof Error
505
- ? { error: err.message }
506
- : {}),
507
758
  },
508
759
  };
509
760
  },
510
761
  });
511
762
  ```
512
763
 
764
+ `mapUnhandledError` response bodies are sent to clients. Use `onCaughtError`
765
+ for diagnostics, logging, and error reporting; keep response `details` limited
766
+ to stable public fields that your app intentionally exposes.
767
+
513
768
  ### Route-level error handling
514
769
 
515
770
  Declare expected business failures on the contract with `.errors(...)`, then
@@ -555,11 +810,17 @@ Creates a Next.js route handler that serves Swagger UI for an OpenAPI endpoint.
555
810
 
556
811
  ### `toRequestLike(req: Request): HttpRequestLike`
557
812
 
558
- Converts a Next.js `Request` to the framework-agnostic `HttpRequestLike` shape.
813
+ Re-export from `@beignet/web`. Converts a standard `Request` to the
814
+ framework-agnostic `HttpRequestLike` shape.
815
+
816
+ ### `toWebResponse(res: HttpResponseLike): Response`
817
+
818
+ Re-export from `@beignet/web`. Converts an `HttpResponseLike` to a standard
819
+ `Response`.
559
820
 
560
821
  ### `toNextResponse(res: HttpResponseLike): Response`
561
822
 
562
- Converts an `HttpResponseLike` to a Next.js `Response`.
823
+ Next-named alias for `toWebResponse(...)`.
563
824
 
564
825
  These are used internally by the adapter but can be used directly if needed.
565
826
 
@@ -569,10 +830,10 @@ These are used internally by the adapter but can be used directly if needed.
569
830
 
570
831
  ```typescript
571
832
  // features/todos/contracts.ts
572
- import { createContractGroup } from "@beignet/core/contracts";
833
+ import { defineContractGroup } from "@beignet/core/contracts";
573
834
  import { z } from "zod";
574
835
 
575
- const todos = createContractGroup()
836
+ const todos = defineContractGroup()
576
837
  .namespace("todos")
577
838
  .prefix("/api/todos");
578
839
 
@@ -606,21 +867,53 @@ export const deleteTodo = todos
606
867
  .delete("/:id")
607
868
  .pathParams(z.object({ id: z.string() }))
608
869
  .responses({ 204: null });
870
+ ```
609
871
 
610
- // server/index.ts
611
- import { createNextServer, defineRoutes } from "@beignet/next";
872
+ ```typescript
873
+ // app-context.ts
874
+ export type AppContext = {
875
+ todos: Array<{ id: string; title: string; completed: boolean }>;
876
+ };
877
+ ```
878
+
879
+ ```typescript
880
+ // features/todos/routes.ts
881
+ import { defineRouteGroup } from "@beignet/next";
882
+ import type { AppContext } from "@/app-context";
612
883
  import * as todosContracts from "@/features/todos/contracts";
613
884
 
614
- export const server = await createNextServer({
615
- ports: {},
616
- routes: defineRoutes([
885
+ export const todoRoutes = defineRouteGroup<AppContext>({
886
+ name: "todos",
887
+ routes: [
617
888
  { contract: todosContracts.listTodos, handle: async () => ({ status: 200, body: [] }) },
618
889
  { contract: todosContracts.getTodo, handle: async ({ path }) => ({ status: 200, body: { id: path.id, title: "...", completed: false } }) },
619
890
  { contract: todosContracts.createTodo, handle: async ({ body }) => ({ status: 201, body: { id: "1", ...body, completed: false } }) },
620
891
  { contract: todosContracts.updateTodo, handle: async ({ path, body }) => ({ status: 200, body: { id: path.id, ...body } }) },
621
892
  { contract: todosContracts.deleteTodo, handle: async () => ({ status: 204 }) },
622
- ]),
623
- createContext: async () => ({ todos: [] }),
893
+ ],
894
+ });
895
+ ```
896
+
897
+ ```typescript
898
+ // server/routes.ts
899
+ import { contractsFromRoutes, defineRoutes } from "@beignet/next";
900
+ import type { AppContext } from "@/app-context";
901
+ import { todoRoutes } from "@/features/todos/routes";
902
+
903
+ export const routes = defineRoutes<AppContext>([todoRoutes]);
904
+ export const contracts = contractsFromRoutes(routes);
905
+ ```
906
+
907
+ ```typescript
908
+ // server/index.ts
909
+ import { createNextServer } from "@beignet/next";
910
+ import type { AppContext } from "@/app-context";
911
+ import { routes } from "@/server/routes";
912
+
913
+ export const server = await createNextServer<AppContext>({
914
+ ports: {},
915
+ routes,
916
+ context: async () => ({ todos: [] }),
624
917
  mapUnhandledError: () => ({
625
918
  status: 500,
626
919
  body: {
@@ -654,7 +947,7 @@ import { getTodo } from "@/features/todos/contracts";
654
947
 
655
948
  export const server = await createNextServer({
656
949
  ports: {},
657
- createContext: async ({ req }) => {
950
+ context: async ({ req }) => {
658
951
  const user = await getUserFromRequest(req);
659
952
 
660
953
  if (!user) {
@@ -698,7 +991,7 @@ export default async function TodosPage() {
698
991
  <div>
699
992
  <h1>Todos</h1>
700
993
  <ul>
701
- {result.todos.map(todo => (
994
+ {result.items.map(todo => (
702
995
  <li key={todo.id}>{todo.title}</li>
703
996
  ))}
704
997
  </ul>