@beignet/next 0.0.1
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/CHANGELOG.md +5 -0
- package/README.md +725 -0
- package/dist/index.d.ts +109 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +307 -0
- package/dist/index.js.map +1 -0
- package/package.json +69 -0
- package/src/index.ts +519 -0
package/README.md
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
# @beignet/next
|
|
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.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @beignet/next next
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
### Optional add-ons
|
|
12
|
+
|
|
13
|
+
- `@beignet/core/openapi` for OpenAPI documentation
|
|
14
|
+
- `@beignet/core/ports` if you want to define shared ports explicitly in your app
|
|
15
|
+
|
|
16
|
+
## TypeScript requirements
|
|
17
|
+
|
|
18
|
+
This package requires TypeScript 5.0 or higher for proper type inference.
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
### 1. Define your contracts
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
// features/todos/contracts.ts
|
|
26
|
+
import { createContractGroup } from "@beignet/core/contracts";
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
|
|
29
|
+
const todos = createContractGroup()
|
|
30
|
+
.namespace("todos")
|
|
31
|
+
.prefix("/api/todos");
|
|
32
|
+
|
|
33
|
+
export const getTodo = todos
|
|
34
|
+
.get("/:id")
|
|
35
|
+
.pathParams(z.object({ id: z.string() }))
|
|
36
|
+
.responses({ 200: z.object({
|
|
37
|
+
id: z.string(),
|
|
38
|
+
title: z.string(),
|
|
39
|
+
completed: z.boolean(),
|
|
40
|
+
}) });
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 2. Create your server
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
// server/index.ts
|
|
47
|
+
import {
|
|
48
|
+
createNextServer,
|
|
49
|
+
defineRouteGroup,
|
|
50
|
+
defineRoutes,
|
|
51
|
+
} from "@beignet/next";
|
|
52
|
+
import { getTodo } from "@/features/todos/contracts";
|
|
53
|
+
|
|
54
|
+
export const server = await createNextServer({
|
|
55
|
+
ports: {},
|
|
56
|
+
routes: defineRoutes([
|
|
57
|
+
{
|
|
58
|
+
contract: getTodo,
|
|
59
|
+
handle: async ({ path }) => ({
|
|
60
|
+
status: 200,
|
|
61
|
+
body: {
|
|
62
|
+
id: path.id,
|
|
63
|
+
title: "Example todo",
|
|
64
|
+
completed: false,
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
]),
|
|
69
|
+
createContext: async ({ req }) => {
|
|
70
|
+
// DEMO ONLY: this reads an unauthenticated header to simulate identity.
|
|
71
|
+
// Real applications should verify a signed token or session cookie first.
|
|
72
|
+
return {
|
|
73
|
+
userId: req.headers.get("x-user-id") || "anonymous",
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
mapUnhandledError: ({ err }) => ({
|
|
77
|
+
status: 500,
|
|
78
|
+
body: {
|
|
79
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
80
|
+
message: "Internal server error",
|
|
81
|
+
...(err instanceof Error ? { details: { message: err.message } } : {}),
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
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
|
+
```
|
|
110
|
+
|
|
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:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
// app/api/[[...path]]/route.ts
|
|
122
|
+
import { server } from "@/server";
|
|
123
|
+
|
|
124
|
+
export const DELETE = server.api;
|
|
125
|
+
export const GET = server.api;
|
|
126
|
+
export const HEAD = server.api;
|
|
127
|
+
export const OPTIONS = server.api;
|
|
128
|
+
export const PATCH = server.api;
|
|
129
|
+
export const POST = server.api;
|
|
130
|
+
export const PUT = server.api;
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
#### Option B: per-contract routes
|
|
134
|
+
|
|
135
|
+
Create individual route files for each contract:
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
// app/api/todos/[id]/route.ts
|
|
139
|
+
import { server } from "@/server";
|
|
140
|
+
import { getTodo } from "@/features/todos/contracts";
|
|
141
|
+
|
|
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
|
+
};
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Raw requests and non-JSON responses
|
|
155
|
+
|
|
156
|
+
`@beignet/next` exposes the underlying web `Request` through
|
|
157
|
+
`HttpRequestLike`. This lets handlers read raw bodies for webhooks while keeping
|
|
158
|
+
the normal JSON contract flow for the rest of the app.
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
export const POST = server.route(stripeWebhook).handle(async ({ req }) => {
|
|
162
|
+
const rawBody = await req.text();
|
|
163
|
+
const signature = req.headers.get("stripe-signature");
|
|
164
|
+
|
|
165
|
+
verifyWebhookSignature(rawBody, signature);
|
|
166
|
+
|
|
167
|
+
return { status: 200, body: { received: true } };
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
For downloads, plain text, and redirects, return a native web `Response`:
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
export const GET = server.route(downloadFile).handle(async () =>
|
|
175
|
+
new Response(await loadFile(), {
|
|
176
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
export const GET = server.route(robotsTxt).handle(async () =>
|
|
181
|
+
new Response("User-agent: *\nAllow: /\n", {
|
|
182
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
export const POST = server.route(startCheckout).handle(async () =>
|
|
187
|
+
Response.redirect("https://checkout.example.com/session/123", 303),
|
|
188
|
+
);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Native `Response` instances intentionally bypass JSON serialization and
|
|
192
|
+
response schema validation. Use `{ status, body }` when you want Beignet to
|
|
193
|
+
validate a JSON response; use `Response` when you want full transport control.
|
|
194
|
+
Response-shaping hooks such as `beforeSend` only run for plain Beignet
|
|
195
|
+
responses; observation hooks such as `afterSend` still receive the final status
|
|
196
|
+
and headers.
|
|
197
|
+
|
|
198
|
+
## API reference
|
|
199
|
+
|
|
200
|
+
### `createNextServer<Ctx>(options)`
|
|
201
|
+
|
|
202
|
+
Creates a Next.js server instance with the given options.
|
|
203
|
+
|
|
204
|
+
**Parameters:**
|
|
205
|
+
- `options`: Same as `createServer` from `@beignet/core/server`:
|
|
206
|
+
- `ports`: **Required** - Ports object defining available service interfaces
|
|
207
|
+
- `createContext`: Async function to create request context
|
|
208
|
+
- `mapUnhandledError`: Error handler function
|
|
209
|
+
- `routes?`: Array of route configurations (contract + handler)
|
|
210
|
+
- `hooks?`: Optional ordered server hooks
|
|
211
|
+
- `providers?`: Optional array of service providers
|
|
212
|
+
- `providerEnv?`: Optional environment variables for providers
|
|
213
|
+
- `providerConfig?`: Optional provider configuration overrides
|
|
214
|
+
|
|
215
|
+
**Returns:** `Promise<NextServer<Ctx>>`
|
|
216
|
+
|
|
217
|
+
### NextServer methods
|
|
218
|
+
|
|
219
|
+
#### `server.api`
|
|
220
|
+
|
|
221
|
+
A Next.js handler for routes registered in `server/index.ts`. Framework-style
|
|
222
|
+
apps usually expose it once from a catch-all API route.
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
// app/api/[[...path]]/route.ts
|
|
226
|
+
import { server } from "@/server";
|
|
227
|
+
|
|
228
|
+
export const DELETE = server.api;
|
|
229
|
+
export const GET = server.api;
|
|
230
|
+
export const HEAD = server.api;
|
|
231
|
+
export const OPTIONS = server.api;
|
|
232
|
+
export const PATCH = server.api;
|
|
233
|
+
export const POST = server.api;
|
|
234
|
+
export const PUT = server.api;
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
#### `server.route(contract)`
|
|
238
|
+
|
|
239
|
+
Returns a route builder for creating custom handlers for a specific contract. The contract is registered globally and available via `server.api`.
|
|
240
|
+
|
|
241
|
+
**Returns:** Route builder with:
|
|
242
|
+
- `handle(fn)`: Create a custom handler function
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
// app/api/todos/[id]/route.ts
|
|
246
|
+
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
|
+
});
|
|
257
|
+
|
|
258
|
+
// Option 2: Call a use case inside the handler
|
|
259
|
+
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
|
+
});
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
#### `server.createContextFromNext()`
|
|
272
|
+
|
|
273
|
+
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
|
+
|
|
275
|
+
**Returns:** `Promise<Ctx>` - Your context object from `createContext`
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
// app/my-page/page.tsx
|
|
279
|
+
import { server } from "@/server";
|
|
280
|
+
import { getTodoUseCase } from "@/features/todos/use-cases/get-todo";
|
|
281
|
+
|
|
282
|
+
export default async function MyPage() {
|
|
283
|
+
// Create context from Next.js headers and cookies
|
|
284
|
+
const ctx = await server.createContextFromNext();
|
|
285
|
+
|
|
286
|
+
// Call use case directly
|
|
287
|
+
const todo = await getTodoUseCase.run({
|
|
288
|
+
ctx,
|
|
289
|
+
input: { id: "123" }
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return <div>{todo.title}</div>;
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
This method:
|
|
297
|
+
- Automatically calls Next.js's `headers()` and `cookies()` functions
|
|
298
|
+
- Creates a minimal Request-like object with headers and cookies access
|
|
299
|
+
- Calls your `createContext` function with this request object
|
|
300
|
+
- 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()`.
|
|
302
|
+
- The `req.url` is set to a placeholder (`http://core/server-component.invalid`) since Server Components don't have real HTTP URLs
|
|
303
|
+
- The `req.json()` and `req.text()` methods return empty values since there's no actual HTTP request body in Server Components
|
|
304
|
+
|
|
305
|
+
**Note:** This method can only be called from Next.js Server Components (not in Client Components or during build time).
|
|
306
|
+
|
|
307
|
+
#### `server.stop()`
|
|
308
|
+
|
|
309
|
+
Stops the server and cleans up resources (closes provider connections, etc.).
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
await server.stop();
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## Handler function context
|
|
316
|
+
|
|
317
|
+
When using `.handle()`, your handler function receives an object with:
|
|
318
|
+
|
|
319
|
+
```typescript
|
|
320
|
+
{
|
|
321
|
+
req: HttpRequestLike, // Raw request object
|
|
322
|
+
ctx: Ctx, // Your custom context from createContext
|
|
323
|
+
path: PathParams, // Validated path parameters
|
|
324
|
+
query: QueryParams, // Validated query parameters
|
|
325
|
+
body: Body, // Validated request body
|
|
326
|
+
contract: Contract, // Resolved contract metadata and schemas
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Use case integration
|
|
331
|
+
|
|
332
|
+
Beignet promotes clean architecture by separating use cases from HTTP concerns. Call use cases from handlers so the HTTP layer stays explicit:
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
// features/todos/use-cases/get-todo.ts
|
|
336
|
+
export async function getTodoUseCase(
|
|
337
|
+
input: { id: string },
|
|
338
|
+
ports: AppPorts
|
|
339
|
+
) {
|
|
340
|
+
return await ports.db.todos.findById(input.id);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// app/api/todos/[id]/route.ts
|
|
344
|
+
export const GET = server
|
|
345
|
+
.route(getTodo)
|
|
346
|
+
.handle(async ({ ctx, path }) => {
|
|
347
|
+
const todo = await getTodoUseCase({ id: path.id }, ctx.ports);
|
|
348
|
+
|
|
349
|
+
return { status: 200, body: todo };
|
|
350
|
+
});
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## Hooks
|
|
354
|
+
|
|
355
|
+
Hooks can be added at the server level:
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
import { createNextServer } from "@beignet/next";
|
|
359
|
+
import { createLoggingHooks } from "@beignet/core/server";
|
|
360
|
+
|
|
361
|
+
const logging = createLoggingHooks({
|
|
362
|
+
logger: console,
|
|
363
|
+
requestIdHeader: "x-request-id",
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
export const server = await createNextServer({
|
|
367
|
+
ports: {},
|
|
368
|
+
hooks: [logging],
|
|
369
|
+
createContext: async () => ({}),
|
|
370
|
+
mapUnhandledError: () => ({
|
|
371
|
+
status: 500,
|
|
372
|
+
body: {
|
|
373
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
374
|
+
message: "Internal server error",
|
|
375
|
+
},
|
|
376
|
+
}),
|
|
377
|
+
});
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
## OpenAPI documentation
|
|
381
|
+
|
|
382
|
+
If you have `@beignet/core/openapi` installed, use `createOpenAPIHandler` for a Next.js route. The handler infers the current request origin and adds it as the OpenAPI server by default.
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
// app/api/openapi/route.ts
|
|
386
|
+
import { createOpenAPIHandler } from "@beignet/next";
|
|
387
|
+
import { server } from "@/server";
|
|
388
|
+
|
|
389
|
+
export const GET = createOpenAPIHandler(server.contracts, {
|
|
390
|
+
title: "My API",
|
|
391
|
+
version: "1.0.0",
|
|
392
|
+
});
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
`server.contracts` is populated from contracts registered through
|
|
396
|
+
`createNextServer({ routes })`. If you export per-file Next handlers with
|
|
397
|
+
`server.route(contract).handle(...)`, keep an explicit contract list or exported
|
|
398
|
+
route registry for OpenAPI because those route files are not imported by the
|
|
399
|
+
server automatically.
|
|
400
|
+
|
|
401
|
+
You can also serve Swagger UI without writing the HTML route by hand:
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
// app/api/docs/route.ts
|
|
405
|
+
import { createSwaggerUIHandler } from "@beignet/next";
|
|
406
|
+
|
|
407
|
+
export const GET = createSwaggerUIHandler({
|
|
408
|
+
title: "My API Documentation",
|
|
409
|
+
specUrl: "/api/openapi",
|
|
410
|
+
});
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
## Public storage routes
|
|
414
|
+
|
|
415
|
+
Use `createStorageRoute` to serve public objects from a `StoragePort` in a
|
|
416
|
+
Next.js App Router route. The route streams object bodies and maps missing
|
|
417
|
+
objects, private objects, invalid keys, and paths outside `basePath` to 404.
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
// app/storage/[...key]/route.ts
|
|
421
|
+
import { createStorageRoute } from "@beignet/next";
|
|
422
|
+
import { server } from "@/server";
|
|
423
|
+
|
|
424
|
+
export const { GET, HEAD } = createStorageRoute(server.ports.storage, {
|
|
425
|
+
basePath: "/storage",
|
|
426
|
+
});
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Served responses preserve object `Content-Type`, `Cache-Control`,
|
|
430
|
+
`Content-Length`, and `Last-Modified` headers when available.
|
|
431
|
+
|
|
432
|
+
## Client creation
|
|
433
|
+
|
|
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}`.
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
// client/api-client.ts
|
|
438
|
+
import { createNextClient } from "@beignet/next";
|
|
439
|
+
|
|
440
|
+
export const apiClient = createNextClient({
|
|
441
|
+
headers: async () => ({}),
|
|
442
|
+
});
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
If your local app runs on a non-default port, provide a server-only fallback:
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
export const apiClient = createNextClient({
|
|
449
|
+
serverBaseUrl: () => `http://localhost:${process.env.PORT || 3002}`,
|
|
450
|
+
});
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
For deployed apps, prefer setting `NEXT_PUBLIC_API_URL` when API calls should target a different origin.
|
|
454
|
+
|
|
455
|
+
## Providers
|
|
456
|
+
|
|
457
|
+
Providers are service adapters that implement ports (database, cache, logger, etc.):
|
|
458
|
+
|
|
459
|
+
```typescript
|
|
460
|
+
import { createNextServer } from "@beignet/next";
|
|
461
|
+
import { createDrizzleTursoProvider } from "@beignet/provider-drizzle-turso";
|
|
462
|
+
import { loggerPinoProvider } from "@beignet/provider-logger-pino";
|
|
463
|
+
import * as schema from "@/db/schema";
|
|
464
|
+
|
|
465
|
+
const drizzleTursoProvider = createDrizzleTursoProvider({ schema });
|
|
466
|
+
|
|
467
|
+
export const server = await createNextServer({
|
|
468
|
+
ports: {},
|
|
469
|
+
providers: [
|
|
470
|
+
drizzleTursoProvider,
|
|
471
|
+
loggerPinoProvider,
|
|
472
|
+
],
|
|
473
|
+
providerEnv: process.env,
|
|
474
|
+
createContext: async ({ ports }) => ({
|
|
475
|
+
// Access providers via ports
|
|
476
|
+
db: ports.db,
|
|
477
|
+
logger: ports.logger,
|
|
478
|
+
}),
|
|
479
|
+
mapUnhandledError: () => ({
|
|
480
|
+
status: 500,
|
|
481
|
+
body: {
|
|
482
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
483
|
+
message: "Internal server error",
|
|
484
|
+
},
|
|
485
|
+
}),
|
|
486
|
+
});
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
## Error handling
|
|
490
|
+
|
|
491
|
+
### Global error handler
|
|
492
|
+
|
|
493
|
+
```typescript
|
|
494
|
+
export const server = await createNextServer({
|
|
495
|
+
ports: {},
|
|
496
|
+
createContext: async () => ({}),
|
|
497
|
+
mapUnhandledError: ({ err, ctx }) => {
|
|
498
|
+
console.error("Unhandled error:", err);
|
|
499
|
+
return {
|
|
500
|
+
status: 500,
|
|
501
|
+
body: {
|
|
502
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
503
|
+
message: "Internal server error",
|
|
504
|
+
...(process.env.NODE_ENV === "development" && err instanceof Error
|
|
505
|
+
? { error: err.message }
|
|
506
|
+
: {}),
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### Route-level error handling
|
|
514
|
+
|
|
515
|
+
Declare expected business failures on the contract with `.errors(...)`, then
|
|
516
|
+
throw your app's catalog helper from handlers or use cases.
|
|
517
|
+
|
|
518
|
+
```typescript
|
|
519
|
+
import { appError } from "@/features/shared/errors";
|
|
520
|
+
|
|
521
|
+
export const GET = server
|
|
522
|
+
.route(getTodo)
|
|
523
|
+
.handle(async ({ ctx, path }) => {
|
|
524
|
+
const todo = await fetchTodoById(path.id);
|
|
525
|
+
|
|
526
|
+
if (!todo) {
|
|
527
|
+
throw appError("TodoNotFound", { details: { id: path.id } });
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return { status: 200, body: todo };
|
|
531
|
+
});
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
## Helper functions
|
|
535
|
+
|
|
536
|
+
### `createNextClient(config?): Client`
|
|
537
|
+
|
|
538
|
+
Creates a `@beignet/core/client` instance with Next.js-friendly base URL defaults.
|
|
539
|
+
|
|
540
|
+
### `resolveNextBaseUrl(config?): string`
|
|
541
|
+
|
|
542
|
+
Resolves the base URL used by `createNextClient`.
|
|
543
|
+
|
|
544
|
+
### `createOpenAPIHandler(contracts, options): (req: Request) => Promise<Response>`
|
|
545
|
+
|
|
546
|
+
Creates a Next.js route handler that returns an OpenAPI 3.1 JSON document. Requires `@beignet/core/openapi` in the app.
|
|
547
|
+
|
|
548
|
+
When you use central route registration, prefer `server.contracts` or
|
|
549
|
+
`contractsFromRoutes(routes)` so OpenAPI is generated from the same route list
|
|
550
|
+
used by the runtime.
|
|
551
|
+
|
|
552
|
+
### `createSwaggerUIHandler(options?): (req: Request) => Response`
|
|
553
|
+
|
|
554
|
+
Creates a Next.js route handler that serves Swagger UI for an OpenAPI endpoint.
|
|
555
|
+
|
|
556
|
+
### `toRequestLike(req: Request): HttpRequestLike`
|
|
557
|
+
|
|
558
|
+
Converts a Next.js `Request` to the framework-agnostic `HttpRequestLike` shape.
|
|
559
|
+
|
|
560
|
+
### `toNextResponse(res: HttpResponseLike): Response`
|
|
561
|
+
|
|
562
|
+
Converts an `HttpResponseLike` to a Next.js `Response`.
|
|
563
|
+
|
|
564
|
+
These are used internally by the adapter but can be used directly if needed.
|
|
565
|
+
|
|
566
|
+
## Examples
|
|
567
|
+
|
|
568
|
+
### Basic CRUD API
|
|
569
|
+
|
|
570
|
+
```typescript
|
|
571
|
+
// features/todos/contracts.ts
|
|
572
|
+
import { createContractGroup } from "@beignet/core/contracts";
|
|
573
|
+
import { z } from "zod";
|
|
574
|
+
|
|
575
|
+
const todos = createContractGroup()
|
|
576
|
+
.namespace("todos")
|
|
577
|
+
.prefix("/api/todos");
|
|
578
|
+
|
|
579
|
+
const todoSchema = z.object({
|
|
580
|
+
id: z.string(),
|
|
581
|
+
title: z.string(),
|
|
582
|
+
completed: z.boolean(),
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
export const listTodos = todos
|
|
586
|
+
.get("/")
|
|
587
|
+
.responses({ 200: z.array(todoSchema) });
|
|
588
|
+
|
|
589
|
+
export const getTodo = todos
|
|
590
|
+
.get("/:id")
|
|
591
|
+
.pathParams(z.object({ id: z.string() }))
|
|
592
|
+
.responses({ 200: todoSchema });
|
|
593
|
+
|
|
594
|
+
export const createTodo = todos
|
|
595
|
+
.post("/")
|
|
596
|
+
.body(z.object({ title: z.string() }))
|
|
597
|
+
.responses({ 201: todoSchema });
|
|
598
|
+
|
|
599
|
+
export const updateTodo = todos
|
|
600
|
+
.put("/:id")
|
|
601
|
+
.pathParams(z.object({ id: z.string() }))
|
|
602
|
+
.body(z.object({ title: z.string(), completed: z.boolean() }))
|
|
603
|
+
.responses({ 200: todoSchema });
|
|
604
|
+
|
|
605
|
+
export const deleteTodo = todos
|
|
606
|
+
.delete("/:id")
|
|
607
|
+
.pathParams(z.object({ id: z.string() }))
|
|
608
|
+
.responses({ 204: null });
|
|
609
|
+
|
|
610
|
+
// server/index.ts
|
|
611
|
+
import { createNextServer, defineRoutes } from "@beignet/next";
|
|
612
|
+
import * as todosContracts from "@/features/todos/contracts";
|
|
613
|
+
|
|
614
|
+
export const server = await createNextServer({
|
|
615
|
+
ports: {},
|
|
616
|
+
routes: defineRoutes([
|
|
617
|
+
{ contract: todosContracts.listTodos, handle: async () => ({ status: 200, body: [] }) },
|
|
618
|
+
{ contract: todosContracts.getTodo, handle: async ({ path }) => ({ status: 200, body: { id: path.id, title: "...", completed: false } }) },
|
|
619
|
+
{ contract: todosContracts.createTodo, handle: async ({ body }) => ({ status: 201, body: { id: "1", ...body, completed: false } }) },
|
|
620
|
+
{ contract: todosContracts.updateTodo, handle: async ({ path, body }) => ({ status: 200, body: { id: path.id, ...body } }) },
|
|
621
|
+
{ contract: todosContracts.deleteTodo, handle: async () => ({ status: 204 }) },
|
|
622
|
+
]),
|
|
623
|
+
createContext: async () => ({ todos: [] }),
|
|
624
|
+
mapUnhandledError: () => ({
|
|
625
|
+
status: 500,
|
|
626
|
+
body: {
|
|
627
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
628
|
+
message: "Internal server error",
|
|
629
|
+
},
|
|
630
|
+
}),
|
|
631
|
+
});
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
```typescript
|
|
635
|
+
// app/api/[[...path]]/route.ts
|
|
636
|
+
import { server } from "@/server";
|
|
637
|
+
|
|
638
|
+
export const DELETE = server.api;
|
|
639
|
+
export const GET = server.api;
|
|
640
|
+
export const HEAD = server.api;
|
|
641
|
+
export const OPTIONS = server.api;
|
|
642
|
+
export const PATCH = server.api;
|
|
643
|
+
export const POST = server.api;
|
|
644
|
+
export const PUT = server.api;
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
### With authentication
|
|
648
|
+
|
|
649
|
+
```typescript
|
|
650
|
+
// server/index.ts
|
|
651
|
+
import { createNextServer } from "@beignet/next";
|
|
652
|
+
import { AuthUnauthorizedError } from "@beignet/core/ports";
|
|
653
|
+
import { getTodo } from "@/features/todos/contracts";
|
|
654
|
+
|
|
655
|
+
export const server = await createNextServer({
|
|
656
|
+
ports: {},
|
|
657
|
+
createContext: async ({ req }) => {
|
|
658
|
+
const user = await getUserFromRequest(req);
|
|
659
|
+
|
|
660
|
+
if (!user) {
|
|
661
|
+
throw new AuthUnauthorizedError();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return { user };
|
|
665
|
+
},
|
|
666
|
+
mapUnhandledError: () => {
|
|
667
|
+
return {
|
|
668
|
+
status: 500,
|
|
669
|
+
body: {
|
|
670
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
671
|
+
message: "Internal server error",
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
### Server component usage
|
|
679
|
+
|
|
680
|
+
You can call use cases directly from React Server Components using `createContextFromNext()`:
|
|
681
|
+
|
|
682
|
+
```typescript
|
|
683
|
+
// app/todos/page.tsx
|
|
684
|
+
import { server } from "@/server";
|
|
685
|
+
import { listTodosUseCase } from "@/features/todos/use-cases/list-todos";
|
|
686
|
+
|
|
687
|
+
export default async function TodosPage() {
|
|
688
|
+
// Create context from Next.js runtime
|
|
689
|
+
const ctx = await server.createContextFromNext();
|
|
690
|
+
|
|
691
|
+
// Call use case directly - no API route needed!
|
|
692
|
+
const result = await listTodosUseCase.run({
|
|
693
|
+
ctx,
|
|
694
|
+
input: { limit: 10, offset: 0 }
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
return (
|
|
698
|
+
<div>
|
|
699
|
+
<h1>Todos</h1>
|
|
700
|
+
<ul>
|
|
701
|
+
{result.todos.map(todo => (
|
|
702
|
+
<li key={todo.id}>{todo.title}</li>
|
|
703
|
+
))}
|
|
704
|
+
</ul>
|
|
705
|
+
</div>
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
This approach:
|
|
711
|
+
- Eliminates unnecessary API routes for server-side data fetching
|
|
712
|
+
- Maintains type safety and business logic separation
|
|
713
|
+
- Automatically handles headers and cookies from Next.js
|
|
714
|
+
- Reuses your existing context creation logic
|
|
715
|
+
|
|
716
|
+
## Related packages
|
|
717
|
+
|
|
718
|
+
- [@beignet/core/server](https://beignet.dev/server) - Framework-agnostic server runtime
|
|
719
|
+
- [@beignet/core/contracts](https://beignet.dev/contracts) - Contract definitions
|
|
720
|
+
- [@beignet/core/openapi](https://beignet.dev/openapi) - OpenAPI spec generation
|
|
721
|
+
- [@beignet/core/client](https://beignet.dev/client) - Type-safe HTTP client
|
|
722
|
+
|
|
723
|
+
## License
|
|
724
|
+
|
|
725
|
+
MIT
|