@beignet/next 0.0.26 → 0.0.28
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 +25 -0
- package/README.md +351 -203
- package/dist/index.d.ts +91 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +128 -21
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/skills/routes-server/SKILL.md +9 -10
- package/src/index.ts +800 -200
package/README.md
CHANGED
|
@@ -122,47 +122,46 @@ export const contracts = contractsFromRoutes(routes);
|
|
|
122
122
|
|
|
123
123
|
```typescript
|
|
124
124
|
// server/index.ts
|
|
125
|
-
import { createNextServer } from "@beignet/next";
|
|
125
|
+
import { createNextServer, createNextServerLoader } from "@beignet/next";
|
|
126
126
|
import type { AppContext } from "@/app-context";
|
|
127
127
|
import { routes } from "@/server/routes";
|
|
128
128
|
|
|
129
|
-
export const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
mapUnhandledError: () => ({
|
|
140
|
-
status: 500,
|
|
141
|
-
body: {
|
|
142
|
-
code: "INTERNAL_SERVER_ERROR",
|
|
143
|
-
message: "Internal server error",
|
|
129
|
+
export const getServer = createNextServerLoader(() =>
|
|
130
|
+
createNextServer<AppContext>({
|
|
131
|
+
ports: {},
|
|
132
|
+
routes,
|
|
133
|
+
context: async ({ req }) => {
|
|
134
|
+
// DEMO ONLY: this reads an unauthenticated header to simulate identity.
|
|
135
|
+
// Real applications should verify a signed token or session cookie first.
|
|
136
|
+
return {
|
|
137
|
+
userId: req.headers.get("x-user-id") || "anonymous",
|
|
138
|
+
};
|
|
144
139
|
},
|
|
140
|
+
mapUnhandledError: () => ({
|
|
141
|
+
status: 500,
|
|
142
|
+
body: {
|
|
143
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
144
|
+
message: "Internal server error",
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
145
147
|
}),
|
|
146
|
-
|
|
148
|
+
);
|
|
147
149
|
```
|
|
148
150
|
|
|
149
151
|
### 5. Set up routes
|
|
150
152
|
|
|
151
153
|
Expose ordinary application routes through one catch-all framework route. Next
|
|
152
|
-
App Router
|
|
153
|
-
|
|
154
|
+
App Router imports route modules during production builds, so keep server boot
|
|
155
|
+
behind the memoized `getServer` loader and expose literal named exports with
|
|
156
|
+
`createApiRoute`:
|
|
154
157
|
|
|
155
158
|
```typescript
|
|
156
159
|
// app/api/[[...path]]/route.ts
|
|
157
|
-
import {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
export const GET =
|
|
161
|
-
|
|
162
|
-
export const OPTIONS = server.api;
|
|
163
|
-
export const PATCH = server.api;
|
|
164
|
-
export const POST = server.api;
|
|
165
|
-
export const PUT = server.api;
|
|
160
|
+
import { createApiRoute } from "@beignet/next";
|
|
161
|
+
import { getServer } from "@/server";
|
|
162
|
+
|
|
163
|
+
export const { DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT } =
|
|
164
|
+
createApiRoute(getServer);
|
|
166
165
|
```
|
|
167
166
|
|
|
168
167
|
This catch-all file is Next.js adapter glue. It forwards requests to the
|
|
@@ -179,13 +178,13 @@ as webhooks, redirects, downloads, or adapter-specific glue:
|
|
|
179
178
|
```typescript
|
|
180
179
|
// app/api/webhooks/payments/route.ts
|
|
181
180
|
import { handlePaymentWebhookUseCase } from "@/features/billing/use-cases";
|
|
182
|
-
import {
|
|
181
|
+
import { getServer } from "@/server";
|
|
183
182
|
import { createPaymentWebhookRoute } from "@beignet/next";
|
|
184
183
|
|
|
185
184
|
export const runtime = "nodejs";
|
|
186
185
|
|
|
187
186
|
export const { POST } = createPaymentWebhookRoute({
|
|
188
|
-
server,
|
|
187
|
+
server: getServer,
|
|
189
188
|
handle: async ({ ctx, event }) => {
|
|
190
189
|
await handlePaymentWebhookUseCase.run({ ctx, input: event });
|
|
191
190
|
return { status: 200, body: { received: true } };
|
|
@@ -202,7 +201,7 @@ validates the typed event catalog before your app handles the event.
|
|
|
202
201
|
|
|
203
202
|
```typescript
|
|
204
203
|
export const { POST } = createWebhookRoute({
|
|
205
|
-
server,
|
|
204
|
+
server: getServer,
|
|
206
205
|
webhook: providerWebhook,
|
|
207
206
|
handle: async ({ ctx, event }) => {
|
|
208
207
|
await handleProviderEventUseCase.run({ ctx, input: event.payload });
|
|
@@ -214,21 +213,27 @@ export const { POST } = createWebhookRoute({
|
|
|
214
213
|
For downloads, plain text, and redirects, return a native web `Response`:
|
|
215
214
|
|
|
216
215
|
```typescript
|
|
217
|
-
|
|
218
|
-
new Response(await loadFile(), {
|
|
219
|
-
headers: { "Content-Type": "application/octet-stream" },
|
|
220
|
-
}),
|
|
221
|
-
);
|
|
216
|
+
import { getServer } from "@/server";
|
|
222
217
|
|
|
223
|
-
export
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
218
|
+
export async function GET(req: Request) {
|
|
219
|
+
const server = await getServer();
|
|
220
|
+
const handle = server.route(downloadFile).handle(async () =>
|
|
221
|
+
new Response(await loadFile(), {
|
|
222
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
228
225
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
226
|
+
return handle(req);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function POST(req: Request) {
|
|
230
|
+
const server = await getServer();
|
|
231
|
+
const handle = server.route(startCheckout).handle(async () =>
|
|
232
|
+
Response.redirect("https://checkout.example.com/session/123", 303),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
return handle(req);
|
|
236
|
+
}
|
|
232
237
|
```
|
|
233
238
|
|
|
234
239
|
Native `Response` instances intentionally bypass JSON serialization and
|
|
@@ -262,29 +267,26 @@ Creates a Next.js server instance with the given options.
|
|
|
262
267
|
|
|
263
268
|
### NextServer methods
|
|
264
269
|
|
|
265
|
-
#### `
|
|
270
|
+
#### `createApiRoute(getServer)`
|
|
266
271
|
|
|
267
|
-
A Next.js
|
|
268
|
-
apps usually expose it once from a catch-all API route.
|
|
272
|
+
A Next.js catch-all route helper for routes registered in `server/index.ts`.
|
|
273
|
+
Framework-style apps usually expose it once from a catch-all API route.
|
|
269
274
|
|
|
270
275
|
```typescript
|
|
271
276
|
// app/api/[[...path]]/route.ts
|
|
272
|
-
import {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
export const GET =
|
|
276
|
-
|
|
277
|
-
export const OPTIONS = server.api;
|
|
278
|
-
export const PATCH = server.api;
|
|
279
|
-
export const POST = server.api;
|
|
280
|
-
export const PUT = server.api;
|
|
277
|
+
import { createApiRoute } from "@beignet/next";
|
|
278
|
+
import { getServer } from "@/server";
|
|
279
|
+
|
|
280
|
+
export const { DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT } =
|
|
281
|
+
createApiRoute(getServer);
|
|
281
282
|
```
|
|
282
283
|
|
|
283
284
|
This route file is not a catch-all contract. Keep contract paths explicit and
|
|
284
285
|
use the file only to expose the central server handler.
|
|
285
286
|
|
|
286
|
-
Next App Router requires literal named exports for each HTTP method
|
|
287
|
-
|
|
287
|
+
Next App Router requires literal named exports for each HTTP method. The route
|
|
288
|
+
helper keeps those exports literal while deferring provider startup until a
|
|
289
|
+
request arrives.
|
|
288
290
|
|
|
289
291
|
#### `server.route(contract)`
|
|
290
292
|
|
|
@@ -292,25 +294,74 @@ Returns a route builder for focused per-file handlers such as webhooks,
|
|
|
292
294
|
redirects, downloads, or other adapter-owned endpoints. Use
|
|
293
295
|
`defineRouteGroup<AppContext>()({ ... })` plus `defineRoutes(...)` for
|
|
294
296
|
ordinary application routes; `server.route(contract).handle(...)` route files
|
|
295
|
-
are not imported by the central
|
|
297
|
+
are not imported by the central API handler.
|
|
296
298
|
|
|
297
299
|
**Returns:** Route builder with:
|
|
298
300
|
- `handle(fn)`: Create a custom handler function
|
|
299
301
|
|
|
300
302
|
```typescript
|
|
301
303
|
// app/api/reports/[id]/download/route.ts
|
|
302
|
-
import {
|
|
304
|
+
import { getServer } from "@/server";
|
|
303
305
|
import { downloadReport } from "@/features/reports/contracts";
|
|
304
306
|
|
|
305
|
-
export
|
|
306
|
-
|
|
307
|
-
.handle(async ({ ctx, path }) =>
|
|
307
|
+
export async function GET(req: Request) {
|
|
308
|
+
const server = await getServer();
|
|
309
|
+
const handle = server.route(downloadReport).handle(async ({ ctx, path }) =>
|
|
308
310
|
new Response(await ctx.ports.reports.loadBytes(path.id), {
|
|
309
311
|
headers: { "Content-Type": "application/pdf" },
|
|
310
312
|
}),
|
|
311
313
|
);
|
|
314
|
+
|
|
315
|
+
return handle(req);
|
|
316
|
+
}
|
|
312
317
|
```
|
|
313
318
|
|
|
319
|
+
#### `server.rawRoute(init)`
|
|
320
|
+
|
|
321
|
+
Builds a handler for a route that cannot be a contract — third-party callback
|
|
322
|
+
endpoints with externally defined request shapes, signature-verified
|
|
323
|
+
webhooks, streaming endpoints — that still runs the whole server pipeline:
|
|
324
|
+
correlation, hooks (rate limiting, idempotency, CORS, logging, error
|
|
325
|
+
reporting), context creation, instrumentation, and framework error mapping.
|
|
326
|
+
Request parsing and validation are skipped and the request body is left
|
|
327
|
+
unconsumed, so the handler owns body reading.
|
|
328
|
+
|
|
329
|
+
`init.name`, `init.method`, and `init.path` identify the route to hooks,
|
|
330
|
+
instrumentation, and devtools — routing itself belongs to the route file
|
|
331
|
+
that mounts the handler. `init.metadata` feeds metadata-driven hooks exactly
|
|
332
|
+
like contract metadata.
|
|
333
|
+
|
|
334
|
+
**Returns:** Builder with `handle(fn)` returning
|
|
335
|
+
`(req: Request) => Promise<Response>`.
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
// app/api/liveblocks-auth/route.ts
|
|
339
|
+
import { getServer } from "@/server";
|
|
340
|
+
|
|
341
|
+
let roomAuth: ((req: Request) => Promise<Response>) | undefined;
|
|
342
|
+
|
|
343
|
+
export async function POST(req: Request) {
|
|
344
|
+
roomAuth ??= (await getServer())
|
|
345
|
+
.rawRoute({
|
|
346
|
+
name: "collab.roomAuth",
|
|
347
|
+
method: "POST",
|
|
348
|
+
path: "/api/liveblocks-auth",
|
|
349
|
+
metadata: { rateLimit: { max: 300, windowSec: 60, scope: "user" } },
|
|
350
|
+
})
|
|
351
|
+
.handle(async ({ req, ctx }) => {
|
|
352
|
+
const body = await req.text();
|
|
353
|
+
return { status: 200, body: { token: "..." } };
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
return roomAuth(req);
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
The webhook, payment webhook, schedule, and outbox drain route factories run
|
|
361
|
+
through this pipeline automatically when the `server` they receive exposes
|
|
362
|
+
`rawRoute(...)`; their `pipeline` option supplies the route identity and
|
|
363
|
+
metadata.
|
|
364
|
+
|
|
314
365
|
#### `server.createContextFromNext()`
|
|
315
366
|
|
|
316
367
|
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.
|
|
@@ -319,11 +370,14 @@ Creates a context object from Next.js Server Components by automatically extract
|
|
|
319
370
|
|
|
320
371
|
```typescript
|
|
321
372
|
// app/my-page/page.tsx
|
|
322
|
-
import {
|
|
373
|
+
import { getServer } from "@/server";
|
|
323
374
|
import { getTodoUseCase } from "@/features/todos/use-cases/get-todo";
|
|
324
375
|
|
|
376
|
+
export const dynamic = "force-dynamic";
|
|
377
|
+
|
|
325
378
|
export default async function MyPage() {
|
|
326
379
|
// Create context from Next.js headers and cookies
|
|
380
|
+
const server = await getServer();
|
|
327
381
|
const ctx = await server.createContextFromNext();
|
|
328
382
|
|
|
329
383
|
// Call use case directly
|
|
@@ -365,9 +419,11 @@ background work:
|
|
|
365
419
|
```typescript
|
|
366
420
|
// server/schedules.ts
|
|
367
421
|
import { createServiceActor } from "@beignet/core/ports";
|
|
368
|
-
import {
|
|
422
|
+
import { getServer } from "./index";
|
|
369
423
|
|
|
370
424
|
export async function createScheduleContext() {
|
|
425
|
+
const server = await getServer();
|
|
426
|
+
|
|
371
427
|
return server.createServiceContext({
|
|
372
428
|
actor: createServiceActor("beignet-schedule"),
|
|
373
429
|
});
|
|
@@ -427,7 +483,7 @@ export const GET = server
|
|
|
427
483
|
Hooks can be added at the server level:
|
|
428
484
|
|
|
429
485
|
```typescript
|
|
430
|
-
import { createNextServer } from "@beignet/next";
|
|
486
|
+
import { createNextServer, createNextServerLoader } from "@beignet/next";
|
|
431
487
|
import { createLoggingHooks } from "@beignet/core/server";
|
|
432
488
|
|
|
433
489
|
const logging = createLoggingHooks({
|
|
@@ -435,18 +491,20 @@ const logging = createLoggingHooks({
|
|
|
435
491
|
requestIdHeader: "x-request-id",
|
|
436
492
|
});
|
|
437
493
|
|
|
438
|
-
export const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
494
|
+
export const getServer = createNextServerLoader(() =>
|
|
495
|
+
createNextServer({
|
|
496
|
+
ports: {},
|
|
497
|
+
hooks: [logging],
|
|
498
|
+
context: async () => ({}),
|
|
499
|
+
mapUnhandledError: () => ({
|
|
500
|
+
status: 500,
|
|
501
|
+
body: {
|
|
502
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
503
|
+
message: "Internal server error",
|
|
504
|
+
},
|
|
505
|
+
}),
|
|
448
506
|
}),
|
|
449
|
-
|
|
507
|
+
);
|
|
450
508
|
```
|
|
451
509
|
|
|
452
510
|
## OpenAPI documentation
|
|
@@ -459,20 +517,20 @@ docs; request-origin inference is available only when you opt in.
|
|
|
459
517
|
// app/api/openapi/route.ts
|
|
460
518
|
import { createOpenAPIHandler } from "@beignet/next";
|
|
461
519
|
import { env } from "@/lib/env";
|
|
462
|
-
import {
|
|
520
|
+
import { contracts } from "@/server/routes";
|
|
463
521
|
|
|
464
|
-
export const GET = createOpenAPIHandler(
|
|
522
|
+
export const GET = createOpenAPIHandler(contracts, {
|
|
465
523
|
title: "My API",
|
|
466
524
|
version: "1.0.0",
|
|
467
525
|
servers: [{ url: env.APP_URL }],
|
|
468
526
|
});
|
|
469
527
|
```
|
|
470
528
|
|
|
471
|
-
`
|
|
472
|
-
|
|
473
|
-
`server.route(contract).handle(...)`,
|
|
474
|
-
|
|
475
|
-
server automatically.
|
|
529
|
+
Export `contracts = contractsFromRoutes(routes)` from `server/routes.ts` so the
|
|
530
|
+
OpenAPI route can stay static and avoid booting providers during Next builds.
|
|
531
|
+
If you export per-file Next handlers with `server.route(contract).handle(...)`,
|
|
532
|
+
keep an explicit contract list because those route files are not imported by
|
|
533
|
+
the server automatically.
|
|
476
534
|
|
|
477
535
|
You can also serve Swagger UI without writing the HTML route by hand:
|
|
478
536
|
|
|
@@ -495,11 +553,14 @@ objects, private objects, invalid keys, and paths outside `basePath` to 404.
|
|
|
495
553
|
```typescript
|
|
496
554
|
// app/storage/[...key]/route.ts
|
|
497
555
|
import { createStorageRoute } from "@beignet/next";
|
|
498
|
-
import {
|
|
556
|
+
import { getServer } from "@/server";
|
|
499
557
|
|
|
500
|
-
export const { GET, HEAD } = createStorageRoute(
|
|
501
|
-
|
|
502
|
-
|
|
558
|
+
export const { GET, HEAD } = createStorageRoute(
|
|
559
|
+
async () => (await getServer()).ports.storage,
|
|
560
|
+
{
|
|
561
|
+
basePath: "/storage",
|
|
562
|
+
},
|
|
563
|
+
);
|
|
503
564
|
```
|
|
504
565
|
|
|
505
566
|
Served responses preserve object `Content-Type`, `Cache-Control`,
|
|
@@ -521,16 +582,18 @@ import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
|
|
|
521
582
|
import { createUploadRouter } from "@beignet/core/uploads";
|
|
522
583
|
import { createUploadRoute } from "@beignet/next";
|
|
523
584
|
import { postUploads } from "@/features/posts/uploads";
|
|
524
|
-
import {
|
|
585
|
+
import { getServer } from "@/server";
|
|
525
586
|
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
ctx: () => server.createContextFromNext(),
|
|
529
|
-
storage: server.ports.storage,
|
|
530
|
-
instrumentation: resolveProviderInstrumentationPort(server.ports),
|
|
531
|
-
});
|
|
587
|
+
export const { POST } = createUploadRoute(async () => {
|
|
588
|
+
const server = await getServer();
|
|
532
589
|
|
|
533
|
-
|
|
590
|
+
return createUploadRouter({
|
|
591
|
+
uploads: postUploads,
|
|
592
|
+
ctx: () => server.createContextFromNext(),
|
|
593
|
+
storage: server.ports.storage,
|
|
594
|
+
instrumentation: resolveProviderInstrumentationPort(server.ports),
|
|
595
|
+
});
|
|
596
|
+
});
|
|
534
597
|
```
|
|
535
598
|
|
|
536
599
|
The `action` segment must be `prepare`, `upload`, or `complete`.
|
|
@@ -539,15 +602,26 @@ The `action` segment must be `prepare`, `upload`, or `complete`.
|
|
|
539
602
|
|
|
540
603
|
Use `createPaymentWebhookRoute(...)` for billing flows backed by
|
|
541
604
|
`ctx.ports.payments`. Use `createWebhookRoute(...)` for generic inbound
|
|
542
|
-
webhooks backed by a `defineWebhook(...)` catalog and a provider verifier.
|
|
543
|
-
|
|
605
|
+
webhooks backed by a `defineWebhook(...)` catalog and a provider verifier.
|
|
606
|
+
The `server` option accepts any object exposing `createRequestContext` — a
|
|
607
|
+
`NextServer`, a core `ServerInstance`, or a test fake — so
|
|
608
|
+
`server: getServer` keeps working unchanged.
|
|
609
|
+
|
|
610
|
+
When the server also exposes `rawRoute(...)` — real Beignet servers do — the
|
|
611
|
+
route runs inside the full hooks pipeline: rate limiting, CORS, logging,
|
|
612
|
+
error reporting, and instrumentation apply, the request appears in devtools,
|
|
613
|
+
and the `pipeline` option supplies the route identity and metadata for
|
|
614
|
+
metadata-driven hooks. The raw body stays unconsumed until the route reads
|
|
615
|
+
it, so signature verification still sees the exact bytes. Minimal test fakes
|
|
616
|
+
without `rawRoute` keep the direct flow: raw body first, then app context
|
|
617
|
+
through `server.createRequestContext(...)`.
|
|
544
618
|
|
|
545
619
|
```typescript
|
|
546
620
|
// app/api/webhooks/github/route.ts
|
|
547
621
|
import { githubWebhook } from "@/features/integrations/webhooks";
|
|
548
622
|
import { handleGitHubWebhookUseCase } from "@/features/integrations/use-cases";
|
|
549
623
|
import { env } from "@/lib/env";
|
|
550
|
-
import {
|
|
624
|
+
import { getServer } from "@/server";
|
|
551
625
|
import { createWebhookRoute } from "@beignet/next";
|
|
552
626
|
import { createGitHubWebhookVerifier } from "@beignet/provider-webhooks-github";
|
|
553
627
|
|
|
@@ -558,7 +632,7 @@ const githubWebhookVerifier = createGitHubWebhookVerifier({
|
|
|
558
632
|
});
|
|
559
633
|
|
|
560
634
|
export const { POST } = createWebhookRoute({
|
|
561
|
-
server,
|
|
635
|
+
server: getServer,
|
|
562
636
|
webhook: githubWebhook,
|
|
563
637
|
verify: ({ input }) => githubWebhookVerifier.verify(input),
|
|
564
638
|
handle: async ({ ctx, event }) => {
|
|
@@ -571,9 +645,10 @@ export const { POST } = createWebhookRoute({
|
|
|
571
645
|
});
|
|
572
646
|
```
|
|
573
647
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
648
|
+
Body read failures return 400 before context creation runs. Verification
|
|
649
|
+
failures return 400 so providers do not treat an invalid signature as a
|
|
650
|
+
fulfilled event. Context creation failures return 500. Handler failures return
|
|
651
|
+
500 so at-least-once webhook providers can retry.
|
|
577
652
|
|
|
578
653
|
Use provider verifiers such as `createGitHubWebhookVerifier(...)` from
|
|
579
654
|
`@beignet/provider-webhooks-github` or `createStripeWebhookVerifier(...)` from
|
|
@@ -589,23 +664,24 @@ billing flows and the route generated by `beignet make payments`.
|
|
|
589
664
|
## Outbox drain routes
|
|
590
665
|
|
|
591
666
|
Use `createOutboxDrainRoute` to expose durable outbox delivery from a cron or
|
|
592
|
-
scheduled serverless route. The helper requires a bearer secret,
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
667
|
+
scheduled serverless route. The helper requires a bearer secret, builds app
|
|
668
|
+
context from the real request with `server.createRequestContext(...)`, drains
|
|
669
|
+
one bounded batch with `@beignet/core/outbox`, records a drain summary into
|
|
670
|
+
the instrumentation port resolved from `ctx.ports` (`ports.instrumentation`,
|
|
671
|
+
then `ports.devtools`), passes request correlation fields to outbox
|
|
596
672
|
instrumentation, and returns a JSON summary.
|
|
597
673
|
|
|
598
674
|
```typescript
|
|
599
675
|
// app/api/cron/outbox/drain/route.ts
|
|
600
676
|
import { createOutboxDrainRoute } from "@beignet/next";
|
|
601
677
|
import { env } from "@/lib/env";
|
|
602
|
-
import {
|
|
678
|
+
import { getServer } from "@/server";
|
|
603
679
|
import { outboxRegistry } from "@/server/outbox";
|
|
604
680
|
|
|
605
681
|
export const runtime = "nodejs";
|
|
606
682
|
|
|
607
683
|
export const { GET, POST } = createOutboxDrainRoute({
|
|
608
|
-
server,
|
|
684
|
+
server: getServer,
|
|
609
685
|
registry: outboxRegistry,
|
|
610
686
|
secret: env.CRON_SECRET,
|
|
611
687
|
batchSize: 100,
|
|
@@ -631,9 +707,10 @@ serverless apps.
|
|
|
631
707
|
## Schedule trigger routes
|
|
632
708
|
|
|
633
709
|
Use `createScheduleRoute` to trigger one registered schedule from a cron or
|
|
634
|
-
scheduled serverless route. The helper requires a bearer secret,
|
|
635
|
-
context with `server.
|
|
636
|
-
inline runner from `@beignet/core/schedules`, and
|
|
710
|
+
scheduled serverless route. The helper requires a bearer secret, builds app
|
|
711
|
+
context from the real request with `server.createRequestContext(...)`, runs
|
|
712
|
+
the schedule with the inline runner from `@beignet/core/schedules`, and
|
|
713
|
+
records `schedule` events
|
|
637
714
|
through the instrumentation port resolved from `ctx.ports`
|
|
638
715
|
(`ports.instrumentation`, then `ports.devtools`) with the request's
|
|
639
716
|
correlation fields.
|
|
@@ -642,13 +719,13 @@ correlation fields.
|
|
|
642
719
|
// app/api/cron/digests/daily-digest/route.ts
|
|
643
720
|
import { createScheduleRoute } from "@beignet/next";
|
|
644
721
|
import { env } from "@/lib/env";
|
|
645
|
-
import {
|
|
722
|
+
import { getServer } from "@/server";
|
|
646
723
|
import { schedules } from "@/server/schedules";
|
|
647
724
|
|
|
648
725
|
export const runtime = "nodejs";
|
|
649
726
|
|
|
650
727
|
export const { GET, POST } = createScheduleRoute({
|
|
651
|
-
server,
|
|
728
|
+
server: getServer,
|
|
652
729
|
schedules,
|
|
653
730
|
schedule: "digests.send-daily",
|
|
654
731
|
secret: env.CRON_SECRET,
|
|
@@ -667,6 +744,62 @@ through `ctx.ports.logger` and return a 500 so schedule providers can retry.
|
|
|
667
744
|
`source` defaults to `"next-cron-route"` and is recorded on run metadata and
|
|
668
745
|
devtools events.
|
|
669
746
|
|
|
747
|
+
## Testing route files
|
|
748
|
+
|
|
749
|
+
The webhook, payment webhook, schedule, and outbox drain route factories build
|
|
750
|
+
app context from the incoming request, not from `next/headers`, so route
|
|
751
|
+
modules execute under `bun test` with a plain `Request`. Import the route
|
|
752
|
+
module directly and call the exported handler:
|
|
753
|
+
|
|
754
|
+
```typescript
|
|
755
|
+
// app/api/webhooks/github/route.test.ts
|
|
756
|
+
import { expect, it } from "bun:test";
|
|
757
|
+
import { POST } from "./route";
|
|
758
|
+
|
|
759
|
+
it("rejects unsigned webhook deliveries", async () => {
|
|
760
|
+
const response = await POST(
|
|
761
|
+
new Request("http://localhost/api/webhooks/github", {
|
|
762
|
+
method: "POST",
|
|
763
|
+
body: JSON.stringify({ action: "opened" }),
|
|
764
|
+
}),
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
expect(response.status).toBe(400);
|
|
768
|
+
});
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
To test a route factory against controlled context without booting the real
|
|
772
|
+
server, pass any object exposing `createRequestContext` as the `server`
|
|
773
|
+
option (`NextRouteServer<Ctx>`):
|
|
774
|
+
|
|
775
|
+
```typescript
|
|
776
|
+
import { expect, it } from "bun:test";
|
|
777
|
+
import { createScheduleRoute } from "@beignet/next";
|
|
778
|
+
import { schedules } from "@/server/schedules";
|
|
779
|
+
|
|
780
|
+
it("triggers the daily digest schedule", async () => {
|
|
781
|
+
const { POST } = createScheduleRoute({
|
|
782
|
+
schedules,
|
|
783
|
+
schedule: "digests.send-daily",
|
|
784
|
+
secret: "test-secret",
|
|
785
|
+
server: {
|
|
786
|
+
async createRequestContext() {
|
|
787
|
+
return createTestScheduleContext();
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
const response = await POST(
|
|
793
|
+
new Request("http://localhost/api/cron/digests/daily-digest", {
|
|
794
|
+
method: "POST",
|
|
795
|
+
headers: { authorization: "Bearer test-secret" },
|
|
796
|
+
}),
|
|
797
|
+
);
|
|
798
|
+
|
|
799
|
+
expect(response.status).toBe(200);
|
|
800
|
+
});
|
|
801
|
+
```
|
|
802
|
+
|
|
670
803
|
## Client creation
|
|
671
804
|
|
|
672
805
|
Use `createClient` from `@beignet/core/client` for modules under `client/` or
|
|
@@ -708,7 +841,7 @@ Beignet supports the normal App Router split:
|
|
|
708
841
|
- Server Components can call use cases directly with `server.createContextFromNext()`.
|
|
709
842
|
- Server Components can prefetch contract queries and hydrate Client Components with TanStack Query.
|
|
710
843
|
- Client Components use `createClient()` plus React Query options for interactive server state.
|
|
711
|
-
- Route handlers stay thin and
|
|
844
|
+
- Route handlers stay thin and use `createApiRoute(getServer)` or focused helpers such as OpenAPI, uploads, storage, devtools, and outbox drains.
|
|
712
845
|
|
|
713
846
|
### Server Component use-case calls
|
|
714
847
|
|
|
@@ -717,9 +850,11 @@ browser cache for the result:
|
|
|
717
850
|
|
|
718
851
|
```typescript
|
|
719
852
|
// app/posts/[slug]/page.tsx
|
|
720
|
-
import {
|
|
853
|
+
import { getServer } from "@/server";
|
|
721
854
|
import { getPostUseCase } from "@/features/posts/use-cases/get-post";
|
|
722
855
|
|
|
856
|
+
export const dynamic = "force-dynamic";
|
|
857
|
+
|
|
723
858
|
type PageProps = {
|
|
724
859
|
params: Promise<{
|
|
725
860
|
slug: string;
|
|
@@ -728,6 +863,7 @@ type PageProps = {
|
|
|
728
863
|
|
|
729
864
|
export default async function Page({ params }: PageProps) {
|
|
730
865
|
const { slug } = await params;
|
|
866
|
+
const server = await getServer();
|
|
731
867
|
const ctx = await server.createContextFromNext();
|
|
732
868
|
const post = await getPostUseCase.run({
|
|
733
869
|
ctx,
|
|
@@ -783,33 +919,35 @@ handlers. Await them before building contract path or query params.
|
|
|
783
919
|
Providers are service adapters that implement ports (database, cache, logger, etc.):
|
|
784
920
|
|
|
785
921
|
```typescript
|
|
786
|
-
import { createNextServer } from "@beignet/next";
|
|
922
|
+
import { createNextServer, createNextServerLoader } from "@beignet/next";
|
|
787
923
|
import { createDrizzleSqliteProvider } from "@beignet/provider-db-drizzle/sqlite";
|
|
788
924
|
import { loggerPinoProvider } from "@beignet/provider-logger-pino";
|
|
789
925
|
import * as schema from "@/db/schema";
|
|
790
926
|
|
|
791
927
|
const drizzleSqliteProvider = createDrizzleSqliteProvider({ schema });
|
|
792
928
|
|
|
793
|
-
export const
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
929
|
+
export const getServer = createNextServerLoader(() =>
|
|
930
|
+
createNextServer({
|
|
931
|
+
ports: {},
|
|
932
|
+
providers: [
|
|
933
|
+
drizzleSqliteProvider,
|
|
934
|
+
loggerPinoProvider,
|
|
935
|
+
],
|
|
936
|
+
providerEnv: process.env,
|
|
937
|
+
context: async ({ ports }) => ({
|
|
938
|
+
// Access providers via ports
|
|
939
|
+
db: ports.db,
|
|
940
|
+
logger: ports.logger,
|
|
941
|
+
}),
|
|
942
|
+
mapUnhandledError: () => ({
|
|
943
|
+
status: 500,
|
|
944
|
+
body: {
|
|
945
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
946
|
+
message: "Internal server error",
|
|
947
|
+
},
|
|
948
|
+
}),
|
|
811
949
|
}),
|
|
812
|
-
|
|
950
|
+
);
|
|
813
951
|
```
|
|
814
952
|
|
|
815
953
|
## Error handling
|
|
@@ -817,20 +955,24 @@ export const server = await createNextServer({
|
|
|
817
955
|
### Global error handler
|
|
818
956
|
|
|
819
957
|
```typescript
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
}
|
|
958
|
+
import { createNextServer, createNextServerLoader } from "@beignet/next";
|
|
959
|
+
|
|
960
|
+
export const getServer = createNextServerLoader(() =>
|
|
961
|
+
createNextServer({
|
|
962
|
+
ports: {},
|
|
963
|
+
context: async () => ({}),
|
|
964
|
+
mapUnhandledError: ({ err }) => {
|
|
965
|
+
console.error("Unhandled error:", err);
|
|
966
|
+
return {
|
|
967
|
+
status: 500,
|
|
968
|
+
body: {
|
|
969
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
970
|
+
message: "Internal server error",
|
|
971
|
+
},
|
|
972
|
+
};
|
|
973
|
+
},
|
|
974
|
+
}),
|
|
975
|
+
);
|
|
834
976
|
```
|
|
835
977
|
|
|
836
978
|
`mapUnhandledError` response bodies are sent to clients. Use `onCaughtError`
|
|
@@ -875,9 +1017,9 @@ Creates a Next.js route handler that returns an OpenAPI 3.1 JSON document. Requi
|
|
|
875
1017
|
Pass `servers` for deployed docs. If `servers` is omitted, no OpenAPI
|
|
876
1018
|
`servers` entry is generated unless `inferServersFromRequest: true` is set.
|
|
877
1019
|
|
|
878
|
-
When you use central route registration,
|
|
879
|
-
`
|
|
880
|
-
|
|
1020
|
+
When you use central route registration, export `contractsFromRoutes(routes)`
|
|
1021
|
+
from `server/routes.ts` so OpenAPI is generated from the same route list used
|
|
1022
|
+
by the runtime without booting providers during route-module import.
|
|
881
1023
|
|
|
882
1024
|
### `createSwaggerUIHandler(options?): (req: Request) => Response`
|
|
883
1025
|
|
|
@@ -885,17 +1027,20 @@ Creates a Next.js route handler that serves Swagger UI for an OpenAPI endpoint.
|
|
|
885
1027
|
|
|
886
1028
|
### `createWebhookRoute(options): { POST }`
|
|
887
1029
|
|
|
888
|
-
Creates a generic Next.js webhook route. The route reads the raw body,
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1030
|
+
Creates a generic Next.js webhook route. The route reads the raw body, builds
|
|
1031
|
+
app context from the real request with `server.createRequestContext(...)`,
|
|
1032
|
+
verifies the event through a webhook definition or context-aware verifier,
|
|
1033
|
+
validates the matching event payload schema, and delegates the event to your
|
|
1034
|
+
app-owned handler.
|
|
892
1035
|
|
|
893
1036
|
### `createPaymentWebhookRoute(options): { POST }`
|
|
894
1037
|
|
|
895
|
-
Creates a Next.js payment webhook route. The route reads the raw body,
|
|
896
|
-
the
|
|
897
|
-
|
|
898
|
-
`
|
|
1038
|
+
Creates a Next.js payment webhook route. The route reads the raw body, builds
|
|
1039
|
+
app context from the real request with `server.createRequestContext(...)`,
|
|
1040
|
+
verifies the provider signature through
|
|
1041
|
+
`ctx.ports.payments.verifyWebhook(...)`, and delegates the normalized event to
|
|
1042
|
+
your app-owned handler. Prefer `createWebhookRoute(...)` for new non-payment
|
|
1043
|
+
webhook integrations.
|
|
899
1044
|
|
|
900
1045
|
### `toRequestLike(req: Request): HttpRequestLike`
|
|
901
1046
|
|
|
@@ -995,66 +1140,66 @@ export const contracts = contractsFromRoutes(routes);
|
|
|
995
1140
|
|
|
996
1141
|
```typescript
|
|
997
1142
|
// server/index.ts
|
|
998
|
-
import { createNextServer } from "@beignet/next";
|
|
1143
|
+
import { createNextServer, createNextServerLoader } from "@beignet/next";
|
|
999
1144
|
import type { AppContext } from "@/app-context";
|
|
1000
1145
|
import { routes } from "@/server/routes";
|
|
1001
1146
|
|
|
1002
|
-
export const
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1147
|
+
export const getServer = createNextServerLoader(() =>
|
|
1148
|
+
createNextServer<AppContext>({
|
|
1149
|
+
ports: {},
|
|
1150
|
+
routes,
|
|
1151
|
+
context: async () => ({ todos: [] }),
|
|
1152
|
+
mapUnhandledError: () => ({
|
|
1153
|
+
status: 500,
|
|
1154
|
+
body: {
|
|
1155
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
1156
|
+
message: "Internal server error",
|
|
1157
|
+
},
|
|
1158
|
+
}),
|
|
1012
1159
|
}),
|
|
1013
|
-
|
|
1160
|
+
);
|
|
1014
1161
|
```
|
|
1015
1162
|
|
|
1016
1163
|
```typescript
|
|
1017
1164
|
// app/api/[[...path]]/route.ts
|
|
1018
|
-
import {
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
export const GET =
|
|
1022
|
-
|
|
1023
|
-
export const OPTIONS = server.api;
|
|
1024
|
-
export const PATCH = server.api;
|
|
1025
|
-
export const POST = server.api;
|
|
1026
|
-
export const PUT = server.api;
|
|
1165
|
+
import { createApiRoute } from "@beignet/next";
|
|
1166
|
+
import { getServer } from "@/server";
|
|
1167
|
+
|
|
1168
|
+
export const { DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT } =
|
|
1169
|
+
createApiRoute(getServer);
|
|
1027
1170
|
```
|
|
1028
1171
|
|
|
1029
1172
|
### With authentication
|
|
1030
1173
|
|
|
1031
1174
|
```typescript
|
|
1032
1175
|
// server/index.ts
|
|
1033
|
-
import { createNextServer } from "@beignet/next";
|
|
1176
|
+
import { createNextServer, createNextServerLoader } from "@beignet/next";
|
|
1034
1177
|
import { AuthUnauthorizedError } from "@beignet/core/ports";
|
|
1035
1178
|
import { getTodo } from "@/features/todos/contracts";
|
|
1036
1179
|
|
|
1037
|
-
export const
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1180
|
+
export const getServer = createNextServerLoader(() =>
|
|
1181
|
+
createNextServer({
|
|
1182
|
+
ports: {},
|
|
1183
|
+
context: async ({ req }) => {
|
|
1184
|
+
const user = await getUserFromRequest(req);
|
|
1041
1185
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1186
|
+
if (!user) {
|
|
1187
|
+
throw new AuthUnauthorizedError();
|
|
1188
|
+
}
|
|
1045
1189
|
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
})
|
|
1190
|
+
return { user };
|
|
1191
|
+
},
|
|
1192
|
+
mapUnhandledError: () => {
|
|
1193
|
+
return {
|
|
1194
|
+
status: 500,
|
|
1195
|
+
body: {
|
|
1196
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
1197
|
+
message: "Internal server error",
|
|
1198
|
+
},
|
|
1199
|
+
};
|
|
1200
|
+
},
|
|
1201
|
+
}),
|
|
1202
|
+
);
|
|
1058
1203
|
```
|
|
1059
1204
|
|
|
1060
1205
|
### Server component usage
|
|
@@ -1063,11 +1208,14 @@ You can call use cases directly from React Server Components using `createContex
|
|
|
1063
1208
|
|
|
1064
1209
|
```typescript
|
|
1065
1210
|
// app/todos/page.tsx
|
|
1066
|
-
import {
|
|
1211
|
+
import { getServer } from "@/server";
|
|
1067
1212
|
import { listTodosUseCase } from "@/features/todos/use-cases/list-todos";
|
|
1068
1213
|
|
|
1214
|
+
export const dynamic = "force-dynamic";
|
|
1215
|
+
|
|
1069
1216
|
export default async function TodosPage() {
|
|
1070
1217
|
// Create context from Next.js runtime
|
|
1218
|
+
const server = await getServer();
|
|
1071
1219
|
const ctx = await server.createContextFromNext();
|
|
1072
1220
|
|
|
1073
1221
|
// Call use case directly - no API route needed!
|