@beignet/next 0.0.3 → 0.0.5

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,15 +1,26 @@
1
1
  # @beignet/next
2
2
 
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
+
3
7
  Next.js adapter for the framework-agnostic `@beignet/core/server` runtime. It
4
8
  builds on `@beignet/web` for standard `Request`/`Response` handling and adds
5
9
  Next-specific helpers for App Router handlers, Server Component context,
6
10
  OpenAPI routes, Swagger UI, uploads, outbox drains, storage routes, and client
7
11
  base URLs.
8
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.
19
+
9
20
  ## Installation
10
21
 
11
22
  ```bash
12
- npm install @beignet/next next
23
+ npm install @beignet/next @beignet/core next
13
24
  ```
14
25
 
15
26
  ### Optional add-ons
@@ -27,10 +38,10 @@ This package requires TypeScript 5.0 or higher for proper type inference.
27
38
 
28
39
  ```typescript
29
40
  // features/todos/contracts.ts
30
- import { createContractGroup } from "@beignet/core/contracts";
41
+ import { defineContractGroup } from "@beignet/core/contracts";
31
42
  import { z } from "zod";
32
43
 
33
- const todos = createContractGroup()
44
+ const todos = defineContractGroup()
34
45
  .namespace("todos")
35
46
  .prefix("/api/todos");
36
47
 
@@ -44,20 +55,30 @@ export const getTodo = todos
44
55
  }) });
45
56
  ```
46
57
 
47
- ### 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`.
48
63
 
49
64
  ```typescript
50
- // server/index.ts
51
- import {
52
- createNextServer,
53
- defineRouteGroup,
54
- defineRoutes,
55
- } 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";
56
77
  import { getTodo } from "@/features/todos/contracts";
57
78
 
58
- export const server = await createNextServer({
59
- ports: {},
60
- routes: defineRoutes([
79
+ export const todoRoutes = defineRouteGroup<AppContext>({
80
+ name: "todos",
81
+ routes: [
61
82
  {
62
83
  contract: getTodo,
63
84
  handle: async ({ path }) => ({
@@ -69,57 +90,59 @@ export const server = await createNextServer({
69
90
  },
70
91
  }),
71
92
  },
72
- ]),
73
- 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 }) => {
74
125
  // DEMO ONLY: this reads an unauthenticated header to simulate identity.
75
126
  // Real applications should verify a signed token or session cookie first.
76
127
  return {
77
128
  userId: req.headers.get("x-user-id") || "anonymous",
78
129
  };
79
130
  },
80
- mapUnhandledError: ({ err }) => ({
131
+ mapUnhandledError: () => ({
81
132
  status: 500,
82
133
  body: {
83
134
  code: "INTERNAL_SERVER_ERROR",
84
135
  message: "Internal server error",
85
- ...(err instanceof Error ? { details: { message: err.message } } : {}),
86
136
  },
87
137
  }),
88
138
  });
89
139
  ```
90
140
 
91
- For larger apps, group related handlers near the feature and compose them with
92
- `defineRoutes`:
141
+ ### 5. Set up routes
93
142
 
94
- ```typescript
95
- const todoRoutes = defineRouteGroup({
96
- name: "todos",
97
- routes: [
98
- {
99
- contract: getTodo,
100
- handle: async ({ path }) => ({
101
- status: 200,
102
- body: {
103
- id: path.id,
104
- title: "Example todo",
105
- completed: false,
106
- },
107
- }),
108
- },
109
- ],
110
- });
111
-
112
- export const routes = defineRoutes([todoRoutes]);
113
- ```
114
-
115
- ### 3. Set up routes
116
-
117
- You have two options for routing:
118
-
119
- #### Option A: catch-all framework route (recommended)
120
-
121
- Register handlers centrally in `server/index.ts`, then expose the central
122
- 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:
123
146
 
124
147
  ```typescript
125
148
  // app/api/[[...path]]/route.ts
@@ -134,24 +157,31 @@ export const POST = server.api;
134
157
  export const PUT = server.api;
135
158
  ```
136
159
 
137
- #### 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]`.
138
164
 
139
- 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:
140
170
 
141
171
  ```typescript
142
- // app/api/todos/[id]/route.ts
172
+ // app/api/webhooks/stripe/route.ts
143
173
  import { server } from "@/server";
144
- import { getTodo } from "@/features/todos/contracts";
174
+ import { stripeWebhook } from "@/features/billing/contracts";
145
175
 
146
- export const GET = server
147
- .route(getTodo)
148
- .handle(async ({ ctx, path }) => {
149
- // Implement your handler logic
150
- const todo = await fetchTodoById(path.id);
151
- return {
152
- status: 200,
153
- body: todo,
154
- };
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 } };
155
185
  });
156
186
  ```
157
187
 
@@ -208,7 +238,10 @@ Creates a Next.js server instance with the given options.
208
238
  **Parameters:**
209
239
  - `options`: Same as `createServer` from `@beignet/core/server`:
210
240
  - `ports`: **Required** - Ports object defining available service interfaces
211
- - `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(...)`
212
245
  - `mapUnhandledError`: Error handler function
213
246
  - `routes?`: Array of route configurations (contract + handler)
214
247
  - `hooks?`: Optional ordered server hooks
@@ -238,45 +271,42 @@ export const POST = server.api;
238
271
  export const PUT = server.api;
239
272
  ```
240
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
+
241
280
  #### `server.route(contract)`
242
281
 
243
- 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.
244
287
 
245
288
  **Returns:** Route builder with:
246
289
  - `handle(fn)`: Create a custom handler function
247
290
 
248
291
  ```typescript
249
- // app/api/todos/[id]/route.ts
292
+ // app/api/reports/[id]/download/route.ts
250
293
  import { server } from "@/server";
251
- import { getTodo } from "@/features/todos/contracts";
252
- import { getTodoUseCase } from "@/features/todos/use-cases/get-todo";
253
-
254
- // Option 1: Custom handler
255
- export const GET = server
256
- .route(getTodo)
257
- .handle(async ({ req, ctx, path, query, body }) => {
258
- // Your implementation
259
- return { status: 200, body: { id: path.id, title: "..." } };
260
- });
294
+ import { downloadReport } from "@/features/reports/contracts";
261
295
 
262
- // Option 2: Call a use case inside the handler
263
296
  export const GET = server
264
- .route(getTodo)
265
- .handle(async ({ ctx, path }) => {
266
- const todo = await getTodoUseCase.run({
267
- ctx,
268
- input: { id: path.id },
269
- });
270
-
271
- return { status: 200, body: todo };
272
- });
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
+ );
273
303
  ```
274
304
 
275
305
  #### `server.createContextFromNext()`
276
306
 
277
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.
278
308
 
279
- **Returns:** `Promise<Ctx>` - Your context object from `createContext`
309
+ **Returns:** `Promise<Ctx>` - Your fully assembled app context
280
310
 
281
311
  ```typescript
282
312
  // app/my-page/page.tsx
@@ -300,14 +330,40 @@ export default async function MyPage() {
300
330
  This method:
301
331
  - Automatically calls Next.js's `headers()` and `cookies()` functions
302
332
  - Creates a minimal Request-like object with headers and cookies access
303
- - 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
304
335
  - Returns the same context type you get in API route handlers
305
- - 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()`.
306
337
  - The `req.url` is set to a placeholder (`http://core/server-component.invalid`) since Server Components don't have real HTTP URLs
307
338
  - The `req.json()` and `req.text()` methods return empty values since there's no actual HTTP request body in Server Components
308
339
 
309
340
  **Note:** This method can only be called from Next.js Server Components (not in Client Components or during build time).
310
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
+
311
367
  #### `server.stop()`
312
368
 
313
369
  Stops the server and cleans up resources (closes provider connections, etc.).
@@ -323,7 +379,7 @@ When using `.handle()`, your handler function receives an object with:
323
379
  ```typescript
324
380
  {
325
381
  req: HttpRequestLike, // Raw request object
326
- ctx: Ctx, // Your custom context from createContext
382
+ ctx: Ctx, // Your custom context from the context blueprint
327
383
  path: PathParams, // Validated path parameters
328
384
  query: QueryParams, // Validated query parameters
329
385
  body: Body, // Validated request body
@@ -370,7 +426,7 @@ const logging = createLoggingHooks({
370
426
  export const server = await createNextServer({
371
427
  ports: {},
372
428
  hooks: [logging],
373
- createContext: async () => ({}),
429
+ context: async () => ({}),
374
430
  mapUnhandledError: () => ({
375
431
  status: 500,
376
432
  body: {
@@ -440,6 +496,7 @@ Router route:
440
496
 
441
497
  ```typescript
442
498
  // app/api/uploads/[uploadName]/[action]/route.ts
499
+ import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
443
500
  import { createUploadRouter } from "@beignet/core/uploads";
444
501
  import { createUploadRoute } from "@beignet/next";
445
502
  import { postUploads } from "@/features/posts/uploads";
@@ -449,7 +506,7 @@ const uploadRouter = createUploadRouter({
449
506
  uploads: postUploads,
450
507
  ctx: () => server.createContextFromNext(),
451
508
  storage: server.ports.storage,
452
- instrumentation: server.ports.devtools,
509
+ instrumentation: resolveProviderInstrumentationPort(server.ports),
453
510
  });
454
511
 
455
512
  export const { POST } = createUploadRoute(uploadRouter);
@@ -461,8 +518,10 @@ The `action` segment must be `prepare`, `upload`, or `complete`.
461
518
 
462
519
  Use `createOutboxDrainRoute` to expose durable outbox delivery from a cron or
463
520
  scheduled serverless route. The helper requires a bearer secret, drains one
464
- bounded batch with `@beignet/core/outbox`, records a devtools event when
465
- available, and returns a JSON summary.
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.
466
525
 
467
526
  ```typescript
468
527
  // app/api/cron/outbox/drain/route.ts
@@ -491,23 +550,76 @@ Call the route from your scheduler with:
491
550
  Authorization: Bearer <CRON_SECRET>
492
551
  ```
493
552
 
553
+ The bearer secret is checked with a timing-safe comparison, and a missing
554
+ secret fails closed with a 500 response.
555
+
494
556
  Do not start long-running outbox polling loops from provider lifecycle hooks in
495
557
  serverless apps.
496
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
+
497
598
  ## Client creation
498
599
 
499
- 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.
500
603
 
501
604
  ```typescript
502
- // client/api-client.ts
503
- import { createNextClient } from "@beignet/next";
605
+ // client/index.ts
606
+ import { createClient } from "@beignet/core/client";
504
607
 
505
- export const apiClient = createNextClient({
608
+ export const apiClient = createClient({
506
609
  headers: async () => ({}),
610
+ validateInput: true,
507
611
  });
508
612
  ```
509
613
 
510
- 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}`.
511
623
 
512
624
  ```typescript
513
625
  export const apiClient = createNextClient({
@@ -517,6 +629,83 @@ export const apiClient = createNextClient({
517
629
 
518
630
  For deployed apps, prefer setting `NEXT_PUBLIC_API_URL` when API calls should target a different origin.
519
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
+
520
709
  ## Providers
521
710
 
522
711
  Providers are service adapters that implement ports (database, cache, logger, etc.):
@@ -536,7 +725,7 @@ export const server = await createNextServer({
536
725
  loggerPinoProvider,
537
726
  ],
538
727
  providerEnv: process.env,
539
- createContext: async ({ ports }) => ({
728
+ context: async ({ ports }) => ({
540
729
  // Access providers via ports
541
730
  db: ports.db,
542
731
  logger: ports.logger,
@@ -558,23 +747,24 @@ export const server = await createNextServer({
558
747
  ```typescript
559
748
  export const server = await createNextServer({
560
749
  ports: {},
561
- createContext: async () => ({}),
562
- mapUnhandledError: ({ err, ctx }) => {
750
+ context: async () => ({}),
751
+ mapUnhandledError: ({ err }) => {
563
752
  console.error("Unhandled error:", err);
564
753
  return {
565
754
  status: 500,
566
755
  body: {
567
756
  code: "INTERNAL_SERVER_ERROR",
568
757
  message: "Internal server error",
569
- ...(process.env.NODE_ENV === "development" && err instanceof Error
570
- ? { error: err.message }
571
- : {}),
572
758
  },
573
759
  };
574
760
  },
575
761
  });
576
762
  ```
577
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
+
578
768
  ### Route-level error handling
579
769
 
580
770
  Declare expected business failures on the contract with `.errors(...)`, then
@@ -640,10 +830,10 @@ These are used internally by the adapter but can be used directly if needed.
640
830
 
641
831
  ```typescript
642
832
  // features/todos/contracts.ts
643
- import { createContractGroup } from "@beignet/core/contracts";
833
+ import { defineContractGroup } from "@beignet/core/contracts";
644
834
  import { z } from "zod";
645
835
 
646
- const todos = createContractGroup()
836
+ const todos = defineContractGroup()
647
837
  .namespace("todos")
648
838
  .prefix("/api/todos");
649
839
 
@@ -677,21 +867,53 @@ export const deleteTodo = todos
677
867
  .delete("/:id")
678
868
  .pathParams(z.object({ id: z.string() }))
679
869
  .responses({ 204: null });
870
+ ```
680
871
 
681
- // server/index.ts
682
- 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";
683
883
  import * as todosContracts from "@/features/todos/contracts";
684
884
 
685
- export const server = await createNextServer({
686
- ports: {},
687
- routes: defineRoutes([
885
+ export const todoRoutes = defineRouteGroup<AppContext>({
886
+ name: "todos",
887
+ routes: [
688
888
  { contract: todosContracts.listTodos, handle: async () => ({ status: 200, body: [] }) },
689
889
  { contract: todosContracts.getTodo, handle: async ({ path }) => ({ status: 200, body: { id: path.id, title: "...", completed: false } }) },
690
890
  { contract: todosContracts.createTodo, handle: async ({ body }) => ({ status: 201, body: { id: "1", ...body, completed: false } }) },
691
891
  { contract: todosContracts.updateTodo, handle: async ({ path, body }) => ({ status: 200, body: { id: path.id, ...body } }) },
692
892
  { contract: todosContracts.deleteTodo, handle: async () => ({ status: 204 }) },
693
- ]),
694
- 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: [] }),
695
917
  mapUnhandledError: () => ({
696
918
  status: 500,
697
919
  body: {
@@ -725,7 +947,7 @@ import { getTodo } from "@/features/todos/contracts";
725
947
 
726
948
  export const server = await createNextServer({
727
949
  ports: {},
728
- createContext: async ({ req }) => {
950
+ context: async ({ req }) => {
729
951
  const user = await getUserFromRequest(req);
730
952
 
731
953
  if (!user) {
@@ -786,10 +1008,10 @@ This approach:
786
1008
 
787
1009
  ## Related packages
788
1010
 
789
- - [@beignet/core/server](https://beignet.dev/server) - Framework-agnostic server runtime
790
- - [@beignet/core/contracts](https://beignet.dev/contracts) - Contract definitions
791
- - [@beignet/core/openapi](https://beignet.dev/openapi) - OpenAPI spec generation
792
- - [@beignet/core/client](https://beignet.dev/client) - Type-safe HTTP client
1011
+ - [@beignet/core/server](https://beignetjs.com/server) - Framework-agnostic server runtime
1012
+ - [@beignet/core/contracts](https://beignetjs.com/contracts) - Contract definitions
1013
+ - [@beignet/core/openapi](https://beignetjs.com/openapi) - OpenAPI spec generation
1014
+ - [@beignet/core/client](https://beignetjs.com/client) - Type-safe HTTP client
793
1015
 
794
1016
  ## License
795
1017