@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/CHANGELOG.md +128 -0
- package/README.md +339 -117
- package/dist/index.d.ts +84 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +115 -16
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +260 -29
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 {
|
|
41
|
+
import { defineContractGroup } from "@beignet/core/contracts";
|
|
31
42
|
import { z } from "zod";
|
|
32
43
|
|
|
33
|
-
const todos =
|
|
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.
|
|
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
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
routes:
|
|
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
|
-
|
|
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: (
|
|
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
|
-
|
|
92
|
-
`defineRoutes`:
|
|
141
|
+
### 5. Set up routes
|
|
93
142
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
172
|
+
// app/api/webhooks/stripe/route.ts
|
|
143
173
|
import { server } from "@/server";
|
|
144
|
-
import {
|
|
174
|
+
import { stripeWebhook } from "@/features/billing/contracts";
|
|
145
175
|
|
|
146
|
-
export const
|
|
147
|
-
.route(
|
|
148
|
-
.handle(async ({
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
- `
|
|
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
|
|
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/
|
|
292
|
+
// app/api/reports/[id]/download/route.ts
|
|
250
293
|
import { server } from "@/server";
|
|
251
|
-
import {
|
|
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(
|
|
265
|
-
.handle(async ({ ctx, path }) =>
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
465
|
-
|
|
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 `
|
|
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/
|
|
503
|
-
import {
|
|
605
|
+
// client/index.ts
|
|
606
|
+
import { createClient } from "@beignet/core/client";
|
|
504
607
|
|
|
505
|
-
export const apiClient =
|
|
608
|
+
export const apiClient = createClient({
|
|
506
609
|
headers: async () => ({}),
|
|
610
|
+
validateInput: true,
|
|
507
611
|
});
|
|
508
612
|
```
|
|
509
613
|
|
|
510
|
-
|
|
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
|
-
|
|
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
|
-
|
|
562
|
-
mapUnhandledError: ({ err
|
|
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 {
|
|
833
|
+
import { defineContractGroup } from "@beignet/core/contracts";
|
|
644
834
|
import { z } from "zod";
|
|
645
835
|
|
|
646
|
-
const todos =
|
|
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
|
-
|
|
682
|
-
|
|
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
|
|
686
|
-
|
|
687
|
-
routes:
|
|
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
|
-
|
|
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
|
-
|
|
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://
|
|
790
|
-
- [@beignet/core/contracts](https://
|
|
791
|
-
- [@beignet/core/openapi](https://
|
|
792
|
-
- [@beignet/core/client](https://
|
|
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
|
|