@beignet/next 0.0.26 → 0.0.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beignet/next",
3
- "version": "0.0.26",
3
+ "version": "0.0.27",
4
4
  "type": "module",
5
5
  "description": "Next.js server-side handlers for Beignet",
6
6
  "main": "./dist/index.js",
@@ -59,7 +59,7 @@
59
59
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0"
60
60
  },
61
61
  "dependencies": {
62
- "@beignet/web": "^0.0.26",
62
+ "@beignet/web": "^0.0.27",
63
63
  "@standard-schema/spec": "^1.0.0"
64
64
  },
65
65
  "devDependencies": {
@@ -64,6 +64,7 @@ registry. If a route is missing from OpenAPI or clients, check this file first.
64
64
  `server/index.ts` owns runtime composition:
65
65
 
66
66
  - `createNextServer(...)`
67
+ - `createNextServerLoader(...)`
67
68
  - app ports
68
69
  - providers
69
70
  - hooks
@@ -79,19 +80,17 @@ feature route files.
79
80
  Application API routes usually use one catch-all file:
80
81
 
81
82
  ```ts
82
- import { server } from "@/server";
83
-
84
- export const DELETE = server.api;
85
- export const GET = server.api;
86
- export const HEAD = server.api;
87
- export const OPTIONS = server.api;
88
- export const PATCH = server.api;
89
- export const POST = server.api;
90
- export const PUT = server.api;
83
+ import { createApiRoute } from "@beignet/next";
84
+ import { getServer } from "@/server";
85
+
86
+ export const { DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT } =
87
+ createApiRoute(getServer);
91
88
  ```
92
89
 
93
90
  The catch-all file is adapter glue. Contracts should still use explicit paths
94
- such as `/projects/:id`, not catch-all contract patterns.
91
+ such as `/projects/:id`, not catch-all contract patterns. Keep provider
92
+ startup behind `getServer` so Next production builds can import route modules
93
+ without booting the app runtime.
95
94
 
96
95
  ## Focused Routes
97
96
 
package/src/index.ts CHANGED
@@ -19,16 +19,18 @@ import {
19
19
  type StandardSchema,
20
20
  } from "@beignet/core/schedules";
21
21
  import type {
22
+ AppEnvironment,
22
23
  ContractLike,
23
24
  CreateServerOptions,
24
25
  Handler,
26
+ HealthConfig,
25
27
  HttpRequestLike,
26
28
  ResolveContract,
27
29
  RouteDef,
28
30
  ServerInstance,
29
31
  StandardSchemaV1,
30
32
  } from "@beignet/core/server";
31
- import { createServer } from "@beignet/core/server";
33
+ import { createHealthHandler, createServer } from "@beignet/core/server";
32
34
  import type { UploadRouter } from "@beignet/core/uploads";
33
35
  import {
34
36
  type InferWebhookEvent,
@@ -38,7 +40,7 @@ import {
38
40
  type WebhookEvent,
39
41
  type WebhookEventSchemas,
40
42
  } from "@beignet/core/webhooks";
41
- import { createFetchHandler, toWebResponse } from "@beignet/web";
43
+ import { createFetchHandler, toRequestLike, toWebResponse } from "@beignet/web";
42
44
 
43
45
  /**
44
46
  * Server types re-exported for Next.js apps.
@@ -81,6 +83,22 @@ type AnyProvider = ServiceProvider<
81
83
  any
82
84
  >;
83
85
 
86
+ type MaybePromise<T> = T | Promise<T>;
87
+
88
+ type ServerSource<TServer> = TServer | (() => MaybePromise<TServer>);
89
+
90
+ function isServerLoader<TServer>(
91
+ source: ServerSource<TServer>,
92
+ ): source is () => MaybePromise<TServer> {
93
+ return typeof source === "function";
94
+ }
95
+
96
+ async function resolveServerSource<TServer>(
97
+ source: ServerSource<TServer>,
98
+ ): Promise<TServer> {
99
+ return isServerLoader(source) ? source() : source;
100
+ }
101
+
84
102
  /**
85
103
  * Beignet client config with Next.js base URL defaults.
86
104
  */
@@ -267,14 +285,30 @@ type OutboxDrainContext = {
267
285
  ports: OutboxDrainPorts;
268
286
  };
269
287
 
288
+ /**
289
+ * Minimal server surface required by the Next.js route factories.
290
+ *
291
+ * Both a `NextServer` and a core `ServerInstance` satisfy this shape
292
+ * structurally, so route modules built on these factories are executable
293
+ * under `bun test`: construct the route with a fake server exposing
294
+ * `createRequestContext` and invoke the exported handlers with a plain
295
+ * `Request`. No Next.js runtime (`next/headers`) is involved.
296
+ */
297
+ export type NextRouteServer<Ctx> = {
298
+ createRequestContext: (req: HttpRequestLike) => Promise<Ctx>;
299
+ };
300
+
270
301
  /**
271
302
  * Options for creating a serverless-safe outbox drain route.
272
303
  */
273
304
  export type CreateOutboxDrainRouteOptions<Ctx extends OutboxDrainContext> = {
274
305
  /**
275
- * Beignet Next server that owns app context and ports.
306
+ * Beignet server that owns app context and ports.
307
+ *
308
+ * Any object exposing `createRequestContext` works: a `NextServer`, a core
309
+ * `ServerInstance`, or a test fake driven from `bun test`.
276
310
  */
277
- server: Pick<NextServer<Ctx, Ctx["ports"]>, "createContextFromNext">;
311
+ server: ServerSource<NextRouteServer<Ctx>>;
278
312
  /**
279
313
  * Registry of events and jobs that may be delivered from the outbox.
280
314
  */
@@ -405,9 +439,12 @@ export type CreateWebhookRouteOptions<
405
439
  AllowUnknownEvents extends boolean = true,
406
440
  > = {
407
441
  /**
408
- * Beignet Next server that owns app context and ports.
442
+ * Beignet server that owns app context and ports.
443
+ *
444
+ * Any object exposing `createRequestContext` works: a `NextServer`, a core
445
+ * `ServerInstance`, or a test fake driven from `bun test`.
409
446
  */
410
- server: Pick<NextServer<Ctx, Ctx["ports"]>, "createContextFromNext">;
447
+ server: ServerSource<NextRouteServer<Ctx>>;
411
448
  /**
412
449
  * Inbound webhook definition.
413
450
  */
@@ -444,9 +481,12 @@ export type CreateWebhookRouteOptions<
444
481
  */
445
482
  export type CreateScheduleRouteOptions<Ctx extends ScheduleRouteContext> = {
446
483
  /**
447
- * Beignet Next server that owns app context and ports.
484
+ * Beignet server that owns app context and ports.
485
+ *
486
+ * Any object exposing `createRequestContext` works: a `NextServer`, a core
487
+ * `ServerInstance`, or a test fake driven from `bun test`.
448
488
  */
449
- server: Pick<NextServer<Ctx, Ctx["ports"]>, "createContextFromNext">;
489
+ server: ServerSource<NextRouteServer<Ctx>>;
450
490
  /**
451
491
  * Registered schedules, usually the `schedules` array from
452
492
  * `server/schedules.ts`.
@@ -483,9 +523,12 @@ export type CreatePaymentWebhookRouteOptions<
483
523
  Ctx extends PaymentWebhookRouteContext,
484
524
  > = {
485
525
  /**
486
- * Beignet Next server that owns app context and ports.
526
+ * Beignet server that owns app context and ports.
527
+ *
528
+ * Any object exposing `createRequestContext` works: a `NextServer`, a core
529
+ * `ServerInstance`, or a test fake driven from `bun test`.
487
530
  */
488
- server: Pick<NextServer<Ctx, Ctx["ports"]>, "createContextFromNext">;
531
+ server: ServerSource<NextRouteServer<Ctx>>;
489
532
  /**
490
533
  * Provider signature header. Stripe uses `stripe-signature`.
491
534
  *
@@ -559,6 +602,18 @@ export interface NextServer<
559
602
  Ports,
560
603
  ServiceInput
561
604
  >["createServiceContext"];
605
+ /**
606
+ * Build a service context and run `fn` inside a scoped ambient correlation
607
+ * frame, returning its result.
608
+ *
609
+ * This is the script-safe counterpart to `createServiceContext(...)`.
610
+ * Requires `context.service` to be declared in `createNextServer(...)`.
611
+ */
612
+ runServiceContext: ServerInstance<
613
+ Ctx,
614
+ Ports,
615
+ ServiceInput
616
+ >["runServiceContext"];
562
617
  /**
563
618
  * Registered contract inputs.
564
619
  */
@@ -573,6 +628,108 @@ export interface NextServer<
573
628
  ports: Ports;
574
629
  }
575
630
 
631
+ /**
632
+ * Lazy, memoized loader for a Beignet Next server.
633
+ */
634
+ export type NextServerLoader<TServer> = () => Promise<TServer>;
635
+
636
+ /**
637
+ * HTTP method handlers for a central Beignet API catch-all route.
638
+ */
639
+ export type NextApiRouteHandlers = {
640
+ DELETE: (req: Request) => Promise<Response>;
641
+ GET: (req: Request) => Promise<Response>;
642
+ HEAD: (req: Request) => Promise<Response>;
643
+ OPTIONS: (req: Request) => Promise<Response>;
644
+ PATCH: (req: Request) => Promise<Response>;
645
+ POST: (req: Request) => Promise<Response>;
646
+ PUT: (req: Request) => Promise<Response>;
647
+ };
648
+
649
+ /**
650
+ * Create a memoized Next server loader for route modules.
651
+ *
652
+ * Next.js imports route modules during production builds. Exporting a lazy
653
+ * loader lets those modules stay cheap to import while still booting providers
654
+ * before the first real request in each runtime process.
655
+ */
656
+ export function createNextServerLoader<TServer>(
657
+ createServer: () => MaybePromise<TServer>,
658
+ ): NextServerLoader<TServer> {
659
+ let server: Promise<TServer> | undefined;
660
+
661
+ return () => {
662
+ if (!server) {
663
+ const nextServer = Promise.resolve().then(createServer);
664
+ server = nextServer;
665
+ void nextServer.catch(() => {
666
+ if (server === nextServer) {
667
+ server = undefined;
668
+ }
669
+ });
670
+ }
671
+
672
+ return server;
673
+ };
674
+ }
675
+
676
+ /**
677
+ * Create HTTP method exports for a catch-all API route.
678
+ */
679
+ export function createApiRoute<
680
+ TServer extends Pick<NextServer<unknown, AnyPorts, unknown>, "api">,
681
+ >(loadServer: NextServerLoader<TServer>): NextApiRouteHandlers {
682
+ const handle = async (req: Request) => {
683
+ const server = await loadServer();
684
+ return server.api(req);
685
+ };
686
+
687
+ return {
688
+ DELETE: handle,
689
+ GET: handle,
690
+ HEAD: handle,
691
+ OPTIONS: handle,
692
+ PATCH: handle,
693
+ POST: handle,
694
+ PUT: handle,
695
+ };
696
+ }
697
+
698
+ /**
699
+ * Create a readiness route whose server is loaded only when the route handles
700
+ * a request.
701
+ */
702
+ export function createHealthRoute<Ports extends AnyPorts>(
703
+ loadServer: NextServerLoader<
704
+ Pick<NextServer<unknown, Ports, unknown>, "ports">
705
+ >,
706
+ healthConfig: HealthConfig<Ports> | undefined,
707
+ environment: AppEnvironment = "production",
708
+ ): {
709
+ GET: (req: Request) => Promise<Response>;
710
+ } {
711
+ return {
712
+ GET: async (req) => {
713
+ const server = await loadServer();
714
+ const handler = createHealthHandler(
715
+ server.ports,
716
+ healthConfig,
717
+ environment,
718
+ );
719
+ const response = await handler(req);
720
+ const headers = new Headers(response.headers);
721
+ if (!headers.has("cache-control")) {
722
+ headers.set("cache-control", "no-store");
723
+ }
724
+
725
+ return Response.json(response.body, {
726
+ status: response.status,
727
+ headers,
728
+ });
729
+ },
730
+ };
731
+ }
732
+
576
733
  /**
577
734
  * Convert a Beignet response into a native `Response`.
578
735
  *
@@ -936,7 +1093,7 @@ export function createSwaggerUIHandler(
936
1093
  * all return 404 so storage routes do not leak object existence.
937
1094
  */
938
1095
  export function createStorageRoute(
939
- storage: StoragePort,
1096
+ storage: ServerSource<StoragePort>,
940
1097
  options: CreateStorageRouteOptions = {},
941
1098
  ): {
942
1099
  GET: (req: Request) => Promise<Response>;
@@ -945,8 +1102,22 @@ export function createStorageRoute(
945
1102
  const basePath = normalizeStorageBasePath(options.basePath);
946
1103
 
947
1104
  return {
948
- GET: (req) => storageResponse(storage, req, basePath, options, true),
949
- HEAD: (req) => storageResponse(storage, req, basePath, options, false),
1105
+ GET: async (req) =>
1106
+ storageResponse(
1107
+ await resolveServerSource(storage),
1108
+ req,
1109
+ basePath,
1110
+ options,
1111
+ true,
1112
+ ),
1113
+ HEAD: async (req) =>
1114
+ storageResponse(
1115
+ await resolveServerSource(storage),
1116
+ req,
1117
+ basePath,
1118
+ options,
1119
+ false,
1120
+ ),
950
1121
  };
951
1122
  }
952
1123
 
@@ -958,7 +1129,7 @@ export function createStorageRoute(
958
1129
  * `prepare`, `complete`, or `upload`.
959
1130
  */
960
1131
  export function createUploadRoute(
961
- router: UploadRouter,
1132
+ router: ServerSource<UploadRouter>,
962
1133
  options: CreateUploadRouteOptions = {},
963
1134
  ): {
964
1135
  POST: (req: Request, ctx?: UploadRouteContext) => Promise<Response>;
@@ -999,7 +1170,9 @@ export function createUploadRoute(
999
1170
  );
1000
1171
  }
1001
1172
 
1002
- return router.handleRequest(req, {
1173
+ const resolvedRouter = await resolveServerSource(router);
1174
+
1175
+ return resolvedRouter.handleRequest(req, {
1003
1176
  uploadName,
1004
1177
  action,
1005
1178
  });
@@ -1052,6 +1225,24 @@ function toHttpResponseHeaders(
1052
1225
  return result;
1053
1226
  }
1054
1227
 
1228
+ /**
1229
+ * Rebuild a request whose body was already consumed.
1230
+ *
1231
+ * Webhook routes read the raw body before anything else so signature
1232
+ * verification never races the context factory over one body stream. Context
1233
+ * creation then receives a rehydrated request carrying the same method, URL,
1234
+ * headers, and raw body.
1235
+ */
1236
+ function toContextRequest(req: Request, rawBody: string): HttpRequestLike {
1237
+ return toRequestLike(
1238
+ new Request(req.url, {
1239
+ method: req.method,
1240
+ headers: req.headers,
1241
+ body: rawBody,
1242
+ }),
1243
+ );
1244
+ }
1245
+
1055
1246
  function headersToRecord(headers: Headers): Record<string, string> {
1056
1247
  const result: Record<string, string> = {};
1057
1248
  headers.forEach((value, key) => {
@@ -1128,7 +1319,8 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
1128
1319
  let ctx: Ctx | undefined;
1129
1320
 
1130
1321
  try {
1131
- ctx = await options.server.createContextFromNext();
1322
+ const server = await resolveServerSource(options.server);
1323
+ ctx = await server.createRequestContext(toRequestLike(req));
1132
1324
  const drainCtx = ctx;
1133
1325
  const result = await drainOutbox({
1134
1326
  outbox: drainCtx.ports.outbox,
@@ -1206,9 +1398,10 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
1206
1398
  * Create a Next.js POST handler for generic verified inbound webhooks.
1207
1399
  *
1208
1400
  * The route reads the raw request body as text, normalizes request headers,
1209
- * verifies the event through the webhook definition or a context-aware
1210
- * verifier, validates the matching event payload schema, and delegates
1211
- * fulfillment to app code.
1401
+ * builds app context from the real request through
1402
+ * `server.createRequestContext(...)`, verifies the event through the webhook
1403
+ * definition or a context-aware verifier, validates the matching event
1404
+ * payload schema, and delegates fulfillment to app code.
1212
1405
  */
1213
1406
  export function createWebhookRoute<
1214
1407
  Ctx extends WebhookRouteContext,
@@ -1224,10 +1417,26 @@ export function createWebhookRoute<
1224
1417
  const headers = resolveHeaders(options.headers, req);
1225
1418
  const requestHeaders = headersToRecord(req.headers);
1226
1419
 
1420
+ let rawBody: string;
1421
+
1422
+ try {
1423
+ rawBody = await req.text();
1424
+ } catch {
1425
+ return webhookRouteJson(
1426
+ {
1427
+ ok: false,
1428
+ error: "Webhook body read failed.",
1429
+ },
1430
+ 400,
1431
+ headers,
1432
+ );
1433
+ }
1434
+
1227
1435
  let ctx: Ctx;
1228
1436
 
1229
1437
  try {
1230
- ctx = await options.server.createContextFromNext();
1438
+ const server = await resolveServerSource(options.server);
1439
+ ctx = await server.createRequestContext(toContextRequest(req, rawBody));
1231
1440
  } catch {
1232
1441
  return webhookRouteJson(
1233
1442
  {
@@ -1239,7 +1448,6 @@ export function createWebhookRoute<
1239
1448
  );
1240
1449
  }
1241
1450
 
1242
- let rawBody: string;
1243
1451
  let event: WebhookRouteHandlerArgs<
1244
1452
  Ctx,
1245
1453
  Webhook,
@@ -1247,7 +1455,6 @@ export function createWebhookRoute<
1247
1455
  >["event"];
1248
1456
 
1249
1457
  try {
1250
- rawBody = await req.text();
1251
1458
  const input: VerifyWebhookInput = {
1252
1459
  rawBody,
1253
1460
  headers: requestHeaders,
@@ -1350,9 +1557,10 @@ export function createWebhookRoute<
1350
1557
  /**
1351
1558
  * Create a Next.js POST handler for provider-verified payment webhooks.
1352
1559
  *
1353
- * The route reads the raw request body as text, extracts the provider
1354
- * signature header, verifies the event through `ctx.ports.payments`, and then
1355
- * delegates fulfillment to app code. This is the canonical helper for Beignet
1560
+ * The route extracts the provider signature header, reads the raw request
1561
+ * body as text, builds app context from the real request through
1562
+ * `server.createRequestContext(...)`, verifies the event through
1563
+ * `ctx.ports.payments`, and then delegates fulfillment to app code. This is the canonical helper for Beignet
1356
1564
  * billing flows backed by `PaymentsPort`. Keep this route on the Node.js
1357
1565
  * runtime when your provider SDK requires Node APIs.
1358
1566
  */
@@ -1381,10 +1589,26 @@ export function createPaymentWebhookRoute<
1381
1589
  );
1382
1590
  }
1383
1591
 
1592
+ let rawBody: string;
1593
+
1594
+ try {
1595
+ rawBody = await req.text();
1596
+ } catch {
1597
+ return paymentWebhookJson(
1598
+ {
1599
+ ok: false,
1600
+ error: "Payment webhook body read failed.",
1601
+ },
1602
+ 400,
1603
+ headers,
1604
+ );
1605
+ }
1606
+
1384
1607
  let ctx: Ctx;
1385
1608
 
1386
1609
  try {
1387
- ctx = await options.server.createContextFromNext();
1610
+ const server = await resolveServerSource(options.server);
1611
+ ctx = await server.createRequestContext(toContextRequest(req, rawBody));
1388
1612
  } catch {
1389
1613
  return paymentWebhookJson(
1390
1614
  {
@@ -1396,11 +1620,9 @@ export function createPaymentWebhookRoute<
1396
1620
  );
1397
1621
  }
1398
1622
 
1399
- let rawBody: string;
1400
1623
  let event: PaymentWebhookEvent;
1401
1624
 
1402
1625
  try {
1403
- rawBody = await req.text();
1404
1626
  event = await ctx.ports.payments.verifyWebhook({
1405
1627
  rawBody,
1406
1628
  signature,
@@ -1476,8 +1698,9 @@ export function createPaymentWebhookRoute<
1476
1698
  * Create Next.js route handlers that trigger one registered schedule.
1477
1699
  *
1478
1700
  * This helper is intended for serverless cron routes. It authenticates the
1479
- * caller with a timing-safe bearer comparison, creates app context, runs the
1480
- * schedule inline, and records `schedule` events through the resolved
1701
+ * caller with a timing-safe bearer comparison, builds app context from the
1702
+ * real request through `server.createRequestContext(...)`, runs the schedule
1703
+ * inline, and records `schedule` events through the resolved
1481
1704
  * provider instrumentation port (`ports.instrumentation`, then
1482
1705
  * `ports.devtools`) when one is installed.
1483
1706
  */
@@ -1524,7 +1747,8 @@ export function createScheduleRoute<Ctx extends ScheduleRouteContext>(
1524
1747
  }
1525
1748
 
1526
1749
  try {
1527
- const ctx = await options.server.createContextFromNext();
1750
+ const server = await resolveServerSource(options.server);
1751
+ const ctx = await server.createRequestContext(toRequestLike(req));
1528
1752
  const runner = createInlineScheduleRunner<Ctx>({
1529
1753
  ctx,
1530
1754
  instrumentation: resolveProviderInstrumentationPort(ctx.ports),
@@ -1641,6 +1865,7 @@ export async function createNextServer<
1641
1865
  },
1642
1866
  createRequestContext: runtime.createRequestContext,
1643
1867
  createServiceContext: runtime.createServiceContext,
1868
+ runServiceContext: runtime.runServiceContext,
1644
1869
  contracts: runtime.contracts,
1645
1870
  stop: () => runtime.stop(),
1646
1871
  ports: runtime.ports,