@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/CHANGELOG.md +155 -0
- package/README.md +407 -114
- package/dist/index.d.ts +289 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +255 -41
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +626 -51
package/README.md
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
# @beignet/next
|
|
2
2
|
|
|
3
|
-
|
|
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 {
|
|
41
|
+
import { defineContractGroup } from "@beignet/core/contracts";
|
|
27
42
|
import { z } from "zod";
|
|
28
43
|
|
|
29
|
-
const todos =
|
|
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.
|
|
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
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
routes:
|
|
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
|
-
|
|
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: (
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
172
|
+
// app/api/webhooks/stripe/route.ts
|
|
139
173
|
import { server } from "@/server";
|
|
140
|
-
import {
|
|
174
|
+
import { stripeWebhook } from "@/features/billing/contracts";
|
|
141
175
|
|
|
142
|
-
export const
|
|
143
|
-
.route(
|
|
144
|
-
.handle(async ({
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
- `
|
|
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
|
|
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/
|
|
292
|
+
// app/api/reports/[id]/download/route.ts
|
|
246
293
|
import { server } from "@/server";
|
|
247
|
-
import {
|
|
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(
|
|
261
|
-
.handle(async ({ ctx, path }) =>
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 `
|
|
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/
|
|
438
|
-
import {
|
|
605
|
+
// client/index.ts
|
|
606
|
+
import { createClient } from "@beignet/core/client";
|
|
439
607
|
|
|
440
|
-
export const apiClient =
|
|
608
|
+
export const apiClient = createClient({
|
|
441
609
|
headers: async () => ({}),
|
|
610
|
+
validateInput: true,
|
|
442
611
|
});
|
|
443
612
|
```
|
|
444
613
|
|
|
445
|
-
|
|
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
|
-
|
|
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
|
-
|
|
497
|
-
mapUnhandledError: ({ err
|
|
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
|
|
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
|
-
|
|
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 {
|
|
833
|
+
import { defineContractGroup } from "@beignet/core/contracts";
|
|
573
834
|
import { z } from "zod";
|
|
574
835
|
|
|
575
|
-
const todos =
|
|
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
|
-
|
|
611
|
-
|
|
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
|
|
615
|
-
|
|
616
|
-
routes:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
994
|
+
{result.items.map(todo => (
|
|
702
995
|
<li key={todo.id}>{todo.title}</li>
|
|
703
996
|
))}
|
|
704
997
|
</ul>
|