@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/src/index.ts CHANGED
@@ -1,41 +1,80 @@
1
1
  import type { ClientConfig } from "@beignet/core/client";
2
2
  import { createClient } from "@beignet/core/client";
3
+ import {
4
+ type DrainOutboxOptions,
5
+ drainOutbox,
6
+ type OutboxRegistry,
7
+ } from "@beignet/core/outbox";
3
8
  import type { AnyPorts, StoragePort } from "@beignet/core/ports";
4
9
  import type {
5
- ProvidedPortsOfList,
10
+ InferProviderPorts,
6
11
  ServiceProvider,
7
12
  } from "@beignet/core/providers";
13
+ import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
14
+ import {
15
+ createInlineScheduleRunner,
16
+ type ScheduleDef,
17
+ type ScheduleInstrumentation,
18
+ type StandardSchema,
19
+ } from "@beignet/core/schedules";
8
20
  import type {
9
21
  ContractLike,
10
22
  CreateServerOptions,
11
23
  Handler,
12
24
  HttpRequestLike,
13
- HttpResponse,
14
25
  ResolveContract,
15
26
  RouteDef,
27
+ ServerInstance,
16
28
  StandardSchemaV1,
17
29
  } from "@beignet/core/server";
18
30
  import { createServer } from "@beignet/core/server";
31
+ import type { UploadRouter } from "@beignet/core/uploads";
32
+ import { createFetchHandler, toWebResponse } from "@beignet/web";
19
33
 
34
+ /**
35
+ * Server types re-exported for Next.js apps.
36
+ */
20
37
  export type {
38
+ AnyUseCaseLike,
21
39
  CreateServerOptions,
40
+ HandlerRouteDef,
41
+ RouteDef,
22
42
  RouteGroup,
43
+ RouteHook,
23
44
  ServerInstance,
45
+ UseCaseRouteDef,
24
46
  } from "@beignet/core/server";
47
+ /**
48
+ * Server helpers re-exported for Next.js apps.
49
+ */
25
50
  export {
26
51
  contractsFromRoutes,
27
52
  createServer,
53
+ defaultBinderInput,
54
+ defineRoute,
28
55
  defineRouteGroup,
29
56
  defineRoutes,
30
57
  } from "@beignet/core/server";
31
58
 
59
+ /**
60
+ * Web Fetch adapter helpers re-exported for Next.js route helpers.
61
+ */
62
+ export { createFetchHandler, toRequestLike, toWebResponse } from "@beignet/web";
63
+
32
64
  type AnyProvider = ServiceProvider<
33
65
  unknown,
34
66
  // biome-ignore lint/suspicious/noExplicitAny: provider config types are erased at this level
35
67
  StandardSchemaV1<any, any>,
36
- AnyPorts
68
+ AnyPorts,
69
+ // biome-ignore lint/suspicious/noExplicitAny: provider context types are erased at this level
70
+ any,
71
+ // biome-ignore lint/suspicious/noExplicitAny: provider service-input types are erased at this level
72
+ any
37
73
  >;
38
74
 
75
+ /**
76
+ * Beignet client config with Next.js base URL defaults.
77
+ */
39
78
  export type NextClientConfig<TProvidedHeaders extends string = never> = Omit<
40
79
  ClientConfig<TProvidedHeaders>,
41
80
  "baseUrl"
@@ -53,9 +92,21 @@ export type NextClientConfig<TProvidedHeaders extends string = never> = Omit<
53
92
  serverBaseUrl?: string | (() => string | undefined);
54
93
  };
55
94
 
95
+ /**
96
+ * OpenAPI server object accepted by `createOpenAPIHandler(...)`.
97
+ */
56
98
  export type OpenAPIServer = {
99
+ /**
100
+ * Server URL.
101
+ */
57
102
  url: string;
103
+ /**
104
+ * Server description.
105
+ */
58
106
  description?: string;
107
+ /**
108
+ * Server URL variables.
109
+ */
59
110
  variables?: Record<
60
111
  string,
61
112
  {
@@ -66,15 +117,45 @@ export type OpenAPIServer = {
66
117
  >;
67
118
  };
68
119
 
120
+ /**
121
+ * Options for the Next.js OpenAPI route handler.
122
+ */
69
123
  export type CreateOpenAPIHandlerOptions = {
124
+ /**
125
+ * API title.
126
+ */
70
127
  title: string;
128
+ /**
129
+ * API version.
130
+ */
71
131
  version: string;
132
+ /**
133
+ * API description.
134
+ */
72
135
  description?: string;
136
+ /**
137
+ * Static servers or a request-aware server factory.
138
+ */
73
139
  servers?: OpenAPIServer[] | ((req: Request) => OpenAPIServer[]);
140
+ /**
141
+ * JSON media type used for generated content.
142
+ */
74
143
  jsonMediaType?: string;
144
+ /**
145
+ * OpenAPI security schemes.
146
+ */
75
147
  securitySchemes?: Record<string, unknown>;
148
+ /**
149
+ * Global security requirements.
150
+ */
76
151
  security?: Array<Record<string, string[]>>;
152
+ /**
153
+ * Custom schema introspector for non-Zod schema libraries.
154
+ */
77
155
  schemaIntrospector?: unknown;
156
+ /**
157
+ * Additional response headers.
158
+ */
78
159
  headers?: HeadersInit | ((req: Request) => HeadersInit | undefined);
79
160
  /**
80
161
  * Add the current request origin as the OpenAPI server when `servers` is omitted.
@@ -84,12 +165,27 @@ export type CreateOpenAPIHandlerOptions = {
84
165
  inferServersFromRequest?: boolean;
85
166
  };
86
167
 
168
+ /**
169
+ * Options for the Next.js Swagger UI route handler.
170
+ */
87
171
  export type CreateSwaggerUIHandlerOptions = {
172
+ /**
173
+ * HTML page title.
174
+ */
88
175
  title?: string;
176
+ /**
177
+ * URL of the OpenAPI JSON route.
178
+ */
89
179
  specUrl?: string | ((req: Request) => string);
180
+ /**
181
+ * Additional response headers.
182
+ */
90
183
  headers?: HeadersInit | ((req: Request) => HeadersInit | undefined);
91
184
  };
92
185
 
186
+ /**
187
+ * Options for serving public storage objects through a Next.js route.
188
+ */
93
189
  export type CreateStorageRouteOptions = {
94
190
  /**
95
191
  * Public path prefix that maps to storage keys.
@@ -103,8 +199,163 @@ export type CreateStorageRouteOptions = {
103
199
  headers?: HeadersInit | ((req: Request) => HeadersInit | undefined);
104
200
  };
105
201
 
106
- export interface NextServer<Ctx, Ports extends AnyPorts = AnyPorts> {
202
+ /**
203
+ * Options for creating a Next.js upload route.
204
+ */
205
+ export type CreateUploadRouteOptions = {
206
+ /**
207
+ * Static upload name. Omit when using a dynamic `[uploadName]` segment.
208
+ */
209
+ uploadName?: string;
210
+ /**
211
+ * Static upload action. Omit when using a dynamic `[action]` segment.
212
+ */
213
+ action?: "prepare" | "complete" | "upload";
214
+ };
215
+
216
+ type OutboxDrainLogger = {
217
+ error(message: string, metadata?: Record<string, unknown>): void;
218
+ };
219
+
220
+ type OutboxDrainInstrumentation = {
221
+ record(event: {
222
+ type: "custom";
223
+ watcher: string;
224
+ name: string;
225
+ label: string;
226
+ summary: string;
227
+ requestId?: string;
228
+ traceId?: string;
229
+ details: unknown;
230
+ }): unknown;
231
+ };
232
+
233
+ type OutboxDrainPorts = {
234
+ outbox: DrainOutboxOptions["outbox"];
235
+ eventBus?: DrainOutboxOptions["eventBus"];
236
+ jobs?: DrainOutboxOptions["jobs"];
237
+ logger?: OutboxDrainLogger;
238
+ devtools?: OutboxDrainInstrumentation;
239
+ instrumentation?: OutboxDrainInstrumentation;
240
+ };
241
+
242
+ type OutboxDrainContext = {
243
+ requestId?: string;
244
+ traceId?: string;
245
+ ports: OutboxDrainPorts;
246
+ };
247
+
248
+ /**
249
+ * Options for creating a serverless-safe outbox drain route.
250
+ */
251
+ export type CreateOutboxDrainRouteOptions<Ctx extends OutboxDrainContext> = {
252
+ /**
253
+ * Beignet Next server that owns app context and ports.
254
+ */
255
+ server: Pick<NextServer<Ctx, Ctx["ports"]>, "createContextFromNext">;
256
+ /**
257
+ * Registry of events and jobs that may be delivered from the outbox.
258
+ */
259
+ registry: OutboxRegistry;
260
+ /**
261
+ * Shared secret expected in the `Authorization: Bearer <secret>` header.
262
+ *
263
+ * Missing secrets fail closed with a 500 response so deployments do not
264
+ * accidentally expose an unauthenticated drain route.
265
+ */
266
+ secret?: string;
267
+ /**
268
+ * Maximum messages to claim in one drain pass.
269
+ */
270
+ batchSize?: number;
271
+ /**
272
+ * Claim lease duration in milliseconds.
273
+ */
274
+ leaseMs?: number;
275
+ /**
276
+ * Retry delay override passed through to `drainOutbox(...)`.
277
+ */
278
+ retryDelayMs?: DrainOutboxOptions["retryDelayMs"];
279
+ /**
280
+ * Additional response headers.
281
+ */
282
+ headers?: HeadersInit | ((req: Request) => HeadersInit | undefined);
283
+ };
284
+
285
+ type ScheduleRouteLogger = {
286
+ error(message: string, metadata?: Record<string, unknown>): void;
287
+ };
288
+
289
+ type ScheduleRoutePorts = {
290
+ logger?: ScheduleRouteLogger;
291
+ devtools?: ScheduleInstrumentation;
292
+ instrumentation?: ScheduleInstrumentation;
293
+ };
294
+
295
+ type ScheduleRouteContext = {
296
+ requestId?: string;
297
+ traceId?: string;
298
+ ports: ScheduleRoutePorts;
299
+ };
300
+
301
+ /**
302
+ * Options for creating a serverless-safe schedule trigger route.
303
+ */
304
+ export type CreateScheduleRouteOptions<Ctx extends ScheduleRouteContext> = {
305
+ /**
306
+ * Beignet Next server that owns app context and ports.
307
+ */
308
+ server: Pick<NextServer<Ctx, Ctx["ports"]>, "createContextFromNext">;
309
+ /**
310
+ * Registered schedules, usually the `schedules` array from
311
+ * `server/schedules.ts`.
312
+ */
313
+ schedules: readonly ScheduleDef<string, StandardSchema, Ctx>[];
314
+ /**
315
+ * Name of the schedule this route triggers. Unknown names throw when the
316
+ * route module loads so misconfigured routes fail at build or boot time.
317
+ */
318
+ schedule: string;
319
+ /**
320
+ * Shared secret expected in the `Authorization: Bearer <secret>` header.
321
+ *
322
+ * Missing secrets fail closed with a 500 response so deployments do not
323
+ * accidentally expose an unauthenticated trigger route.
324
+ */
325
+ secret?: string;
326
+ /**
327
+ * Run source label recorded on run metadata and devtools events.
328
+ *
329
+ * @default "next-cron-route"
330
+ */
331
+ source?: string;
332
+ /**
333
+ * Additional response headers.
334
+ */
335
+ headers?: HeadersInit | ((req: Request) => HeadersInit | undefined);
336
+ };
337
+
338
+ type UploadRouteContext = {
339
+ params?:
340
+ | Promise<Record<string, string | string[] | undefined>>
341
+ | Record<string, string | string[] | undefined>;
342
+ };
343
+
344
+ /**
345
+ * Beignet server adapted for Next.js route handlers and server components.
346
+ */
347
+ export interface NextServer<
348
+ Ctx,
349
+ Ports extends AnyPorts = AnyPorts,
350
+ ServiceInput = void,
351
+ > {
352
+ /**
353
+ * Catch-all Next.js route handler.
354
+ */
107
355
  api: (req: Request) => Promise<Response>;
356
+ /**
357
+ * Register and build a single Next.js route handler imperatively.
358
+ */
108
359
  route: <CLike extends ContractLike>(
109
360
  contractLike: CLike,
110
361
  ) => {
@@ -112,42 +363,49 @@ export interface NextServer<Ctx, Ports extends AnyPorts = AnyPorts> {
112
363
  fn: Handler<Ctx, ResolveContract<CLike>>,
113
364
  ) => (req: Request) => Promise<Response>;
114
365
  };
366
+ /**
367
+ * Create app context from `next/headers` for Server Components.
368
+ */
115
369
  createContextFromNext: () => Promise<Ctx>;
370
+ /**
371
+ * Build a fully assembled request context from a framework-neutral request.
372
+ */
373
+ createRequestContext: ServerInstance<
374
+ Ctx,
375
+ Ports,
376
+ ServiceInput
377
+ >["createRequestContext"];
378
+ /**
379
+ * Build a fully assembled service context for schedules, outbox drains,
380
+ * commands, and background work.
381
+ *
382
+ * Requires `context.service` to be declared in `createNextServer(...)`.
383
+ */
384
+ createServiceContext: ServerInstance<
385
+ Ctx,
386
+ Ports,
387
+ ServiceInput
388
+ >["createServiceContext"];
389
+ /**
390
+ * Registered contract inputs.
391
+ */
116
392
  contracts: readonly ContractLike[];
393
+ /**
394
+ * Stop installed providers.
395
+ */
117
396
  stop: () => Promise<void>;
397
+ /**
398
+ * Final app ports after provider setup.
399
+ */
118
400
  ports: Ports;
119
401
  }
120
402
 
121
- export function toRequestLike(req: Request): HttpRequestLike {
122
- return {
123
- method: req.method,
124
- url: req.url,
125
- headers: new Headers(req.headers),
126
- raw: req,
127
- json: () => req.json(),
128
- text: () => req.text(),
129
- arrayBuffer: () => req.arrayBuffer(),
130
- blob: () => req.blob(),
131
- formData: () => req.formData(),
132
- clone: () => toRequestLike(req.clone()),
133
- };
134
- }
135
-
136
- export function toNextResponse(resLike: HttpResponse): Response {
137
- if (resLike instanceof Response) {
138
- return resLike;
139
- }
140
-
141
- const headers = new Headers(resLike.headers);
142
- const body = resLike.body;
143
-
144
- if (body === undefined || body === null) {
145
- return new Response(null, { status: resLike.status, headers });
146
- }
147
-
148
- // biome-ignore lint/suspicious/noExplicitAny: Body can be any JSON-serializable value
149
- return Response.json(body as any, { status: resLike.status, headers });
150
- }
403
+ /**
404
+ * Convert a Beignet response into a native `Response`.
405
+ *
406
+ * This is the Next-named alias for `toWebResponse(...)` from `@beignet/web`.
407
+ */
408
+ export const toNextResponse = toWebResponse;
151
409
 
152
410
  function resolveConfigValue(
153
411
  value: string | (() => string | undefined) | undefined,
@@ -280,6 +538,10 @@ function storageNotFoundResponse(): Response {
280
538
  return new Response(null, { status: 404 });
281
539
  }
282
540
 
541
+ function firstParam(value: string | string[] | undefined): string | undefined {
542
+ return Array.isArray(value) ? value[0] : value;
543
+ }
544
+
283
545
  async function storageResponse(
284
546
  storage: StoragePort,
285
547
  req: Request,
@@ -456,37 +718,351 @@ export function createStorageRoute(
456
718
  };
457
719
  }
458
720
 
721
+ /**
722
+ * Create a Next.js POST handler for Beignet upload routes.
723
+ *
724
+ * Use this in a dynamic route such as
725
+ * `app/api/uploads/[uploadName]/[action]/route.ts`, where `action` is
726
+ * `prepare`, `complete`, or `upload`.
727
+ */
728
+ export function createUploadRoute(
729
+ router: UploadRouter,
730
+ options: CreateUploadRouteOptions = {},
731
+ ): {
732
+ POST: (req: Request, ctx?: UploadRouteContext) => Promise<Response>;
733
+ } {
734
+ return {
735
+ POST: async (req, ctx) => {
736
+ const params = await ctx?.params;
737
+ const uploadName =
738
+ options.uploadName ?? firstParam(params?.uploadName ?? params?.upload);
739
+ const action = options.action ?? firstParam(params?.action);
740
+
741
+ if (!uploadName) {
742
+ return Response.json(
743
+ {
744
+ error: {
745
+ code: "UPLOAD_NOT_FOUND",
746
+ message: "Upload route is missing an upload name.",
747
+ },
748
+ },
749
+ { status: 404 },
750
+ );
751
+ }
752
+
753
+ if (
754
+ action !== "prepare" &&
755
+ action !== "complete" &&
756
+ action !== "upload"
757
+ ) {
758
+ return Response.json(
759
+ {
760
+ error: {
761
+ code: "INVALID_UPLOAD_ACTION",
762
+ message:
763
+ 'Upload route action must be "prepare", "complete", or "upload".',
764
+ },
765
+ },
766
+ { status: 400 },
767
+ );
768
+ }
769
+
770
+ return router.handleRequest(req, {
771
+ uploadName,
772
+ action,
773
+ });
774
+ },
775
+ };
776
+ }
777
+
778
+ function cronRouteJson(
779
+ body: unknown,
780
+ status: number,
781
+ headers: HeadersInit | undefined,
782
+ ): Response {
783
+ return Response.json(body, {
784
+ status,
785
+ headers,
786
+ });
787
+ }
788
+
789
+ /**
790
+ * Compare two strings without leaking length or prefix timing.
791
+ *
792
+ * Both values are hashed with SHA-256 so the XOR comparison always runs over
793
+ * fixed-size digests, which keeps the check edge-compatible and constant-time.
794
+ */
795
+ async function timingSafeStringEqual(a: string, b: string): Promise<boolean> {
796
+ const encoder = new TextEncoder();
797
+ const [digestA, digestB] = await Promise.all([
798
+ crypto.subtle.digest("SHA-256", encoder.encode(a)),
799
+ crypto.subtle.digest("SHA-256", encoder.encode(b)),
800
+ ]);
801
+ const bytesA = new Uint8Array(digestA);
802
+ const bytesB = new Uint8Array(digestB);
803
+
804
+ let mismatch = 0;
805
+ for (let index = 0; index < bytesA.length; index++) {
806
+ mismatch |= (bytesA[index] ?? 0) ^ (bytesB[index] ?? 0);
807
+ }
808
+
809
+ return mismatch === 0;
810
+ }
811
+
812
+ async function isAuthorizedCronRequest(
813
+ req: Request,
814
+ secret: string,
815
+ ): Promise<boolean> {
816
+ return timingSafeStringEqual(
817
+ req.headers.get("authorization") ?? "",
818
+ `Bearer ${secret}`,
819
+ );
820
+ }
821
+
822
+ /**
823
+ * Create Next.js route handlers that drain one durable outbox batch.
824
+ *
825
+ * This helper is intended for serverless cron routes. It performs bounded work
826
+ * and exits; do not start long-running outbox polling loops from provider
827
+ * lifecycle hooks in serverless apps.
828
+ */
829
+ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
830
+ options: CreateOutboxDrainRouteOptions<Ctx>,
831
+ ): {
832
+ GET: (req: Request) => Promise<Response>;
833
+ POST: (req: Request) => Promise<Response>;
834
+ } {
835
+ const drainPendingOutbox = async (req: Request): Promise<Response> => {
836
+ const headers = resolveHeaders(options.headers, req);
837
+ const secret = options.secret;
838
+
839
+ if (!secret) {
840
+ return cronRouteJson(
841
+ {
842
+ ok: false,
843
+ error: "CRON_SECRET is not configured.",
844
+ },
845
+ 500,
846
+ headers,
847
+ );
848
+ }
849
+
850
+ if (!(await isAuthorizedCronRequest(req, secret))) {
851
+ return cronRouteJson({ ok: false, error: "Unauthorized" }, 401, headers);
852
+ }
853
+
854
+ let ctx: Ctx | undefined;
855
+
856
+ try {
857
+ ctx = await options.server.createContextFromNext();
858
+ const drainCtx = ctx;
859
+ const result = await drainOutbox({
860
+ outbox: drainCtx.ports.outbox,
861
+ registry: options.registry,
862
+ eventBus: drainCtx.ports.eventBus,
863
+ jobs: drainCtx.ports.jobs,
864
+ batchSize: options.batchSize,
865
+ leaseMs: options.leaseMs,
866
+ retryDelayMs: options.retryDelayMs,
867
+ instrumentation: resolveProviderInstrumentationPort(drainCtx.ports),
868
+ instrumentationContext: {
869
+ requestId: drainCtx.requestId,
870
+ traceId: drainCtx.traceId,
871
+ },
872
+ onError(error, message) {
873
+ drainCtx.ports.logger?.error("Outbox delivery failed", {
874
+ error,
875
+ outboxMessageId: message.id,
876
+ kind: message.kind,
877
+ name: message.name,
878
+ attempts: message.attempts,
879
+ });
880
+ },
881
+ });
882
+
883
+ try {
884
+ await resolveProviderInstrumentationPort(drainCtx.ports)?.record({
885
+ type: "custom",
886
+ watcher: "outbox",
887
+ name: "outbox.drain",
888
+ label: "Outbox drain",
889
+ summary: `${result.delivered} delivered, ${result.retried} retried, ${result.deadLettered} dead-lettered`,
890
+ requestId: drainCtx.requestId,
891
+ traceId: drainCtx.traceId,
892
+ details: result,
893
+ });
894
+ } catch (error) {
895
+ drainCtx.ports.logger?.error(
896
+ "Outbox drain instrumentation recording failed",
897
+ {
898
+ error,
899
+ },
900
+ );
901
+ }
902
+
903
+ return cronRouteJson(
904
+ {
905
+ ok: true,
906
+ result,
907
+ },
908
+ 200,
909
+ headers,
910
+ );
911
+ } catch (error) {
912
+ ctx?.ports.logger?.error("Outbox drain failed", { error });
913
+
914
+ return cronRouteJson(
915
+ {
916
+ ok: false,
917
+ error: "Outbox drain failed.",
918
+ },
919
+ 500,
920
+ headers,
921
+ );
922
+ }
923
+ };
924
+
925
+ return {
926
+ GET: drainPendingOutbox,
927
+ POST: drainPendingOutbox,
928
+ };
929
+ }
930
+
931
+ /**
932
+ * Create Next.js route handlers that trigger one registered schedule.
933
+ *
934
+ * This helper is intended for serverless cron routes. It authenticates the
935
+ * caller with a timing-safe bearer comparison, creates app context, runs the
936
+ * schedule inline, and records `schedule` events through the resolved
937
+ * provider instrumentation port (`ports.instrumentation`, then
938
+ * `ports.devtools`) when one is installed.
939
+ */
940
+ export function createScheduleRoute<Ctx extends ScheduleRouteContext>(
941
+ options: CreateScheduleRouteOptions<Ctx>,
942
+ ): {
943
+ GET: (req: Request) => Promise<Response>;
944
+ POST: (req: Request) => Promise<Response>;
945
+ } {
946
+ const schedule = options.schedules.find(
947
+ (candidate) => candidate.name === options.schedule,
948
+ );
949
+
950
+ if (!schedule) {
951
+ const available = options.schedules
952
+ .map((candidate) => candidate.name)
953
+ .sort();
954
+
955
+ throw new Error(
956
+ available.length > 0
957
+ ? `Unknown schedule "${options.schedule}". Available schedules: ${available.join(", ")}.`
958
+ : `Unknown schedule "${options.schedule}". No schedules were passed to createScheduleRoute(...).`,
959
+ );
960
+ }
961
+
962
+ const runScheduleFromRequest = async (req: Request): Promise<Response> => {
963
+ const headers = resolveHeaders(options.headers, req);
964
+ const secret = options.secret;
965
+
966
+ if (!secret) {
967
+ return cronRouteJson(
968
+ {
969
+ ok: false,
970
+ error: "CRON_SECRET is not configured.",
971
+ scheduleName: schedule.name,
972
+ },
973
+ 500,
974
+ headers,
975
+ );
976
+ }
977
+
978
+ if (!(await isAuthorizedCronRequest(req, secret))) {
979
+ return cronRouteJson({ ok: false, error: "Unauthorized" }, 401, headers);
980
+ }
981
+
982
+ try {
983
+ const ctx = await options.server.createContextFromNext();
984
+ const runner = createInlineScheduleRunner<Ctx>({
985
+ ctx,
986
+ instrumentation: resolveProviderInstrumentationPort(ctx.ports),
987
+ instrumentationContext: {
988
+ requestId: ctx.requestId,
989
+ traceId: ctx.traceId,
990
+ },
991
+ onError({ error }) {
992
+ ctx.ports.logger?.error("Schedule failed", {
993
+ error,
994
+ scheduleName: schedule.name,
995
+ });
996
+ },
997
+ });
998
+
999
+ await runner.run(schedule, {
1000
+ source: options.source ?? "next-cron-route",
1001
+ });
1002
+
1003
+ return cronRouteJson(
1004
+ {
1005
+ ok: true,
1006
+ scheduleName: schedule.name,
1007
+ },
1008
+ 200,
1009
+ headers,
1010
+ );
1011
+ } catch {
1012
+ return cronRouteJson(
1013
+ {
1014
+ ok: false,
1015
+ error: "Schedule failed.",
1016
+ scheduleName: schedule.name,
1017
+ },
1018
+ 500,
1019
+ headers,
1020
+ );
1021
+ }
1022
+ };
1023
+
1024
+ return {
1025
+ GET: runScheduleFromRequest,
1026
+ POST: runScheduleFromRequest,
1027
+ };
1028
+ }
1029
+
1030
+ /**
1031
+ * Create a Beignet server adapted for Next.js.
1032
+ *
1033
+ * The returned `api` handler is intended for a catch-all route. Focused routes
1034
+ * such as OpenAPI, Swagger UI, devtools, and public storage can use their own
1035
+ * route helpers.
1036
+ */
459
1037
  export async function createNextServer<
460
1038
  Ctx,
461
1039
  Ports extends AnyPorts = AnyPorts,
1040
+ ServiceInput = void,
462
1041
  Routes extends readonly RouteDef<Ctx>[] = readonly RouteDef<Ctx>[],
463
1042
  Providers extends readonly AnyProvider[] = readonly AnyProvider[],
464
1043
  >(
465
- options: CreateServerOptions<Ctx, Ports, Routes, Providers>,
466
- ): Promise<NextServer<Ctx, Ports & ProvidedPortsOfList<Providers>>> {
1044
+ options: CreateServerOptions<Ctx, Ports, ServiceInput, Routes, Providers>,
1045
+ ): Promise<
1046
+ NextServer<Ctx, Ports & InferProviderPorts<Providers>, ServiceInput>
1047
+ > {
467
1048
  const runtime = await createServer(options);
468
1049
 
469
- const wrap = (handler: (req: HttpRequestLike) => Promise<HttpResponse>) => {
470
- return async (req: Request) => {
471
- const res = await handler(toRequestLike(req));
472
- return toNextResponse(res);
473
- };
474
- };
475
-
476
1050
  return {
477
- api: wrap(runtime.api),
1051
+ api: createFetchHandler(runtime),
478
1052
  route: (contract) => ({
479
- handle: (fn) => wrap(runtime.route(contract).handle(fn)),
1053
+ handle: (fn) => createFetchHandler(runtime.route(contract).handle(fn)),
480
1054
  }),
481
1055
  createContextFromNext: async () => {
482
- const { cookies, headers } = await import("next/headers");
1056
+ // "next" ships no package exports map, so Node-style ESM resolution
1057
+ // (NodeNext) needs the explicit .js subpath. Bundlers resolve it the same.
1058
+ const { cookies, headers } = await import("next/headers.js");
483
1059
  const h = await headers();
484
1060
  const c = await cookies();
485
1061
 
486
1062
  // Minimal Request-like object for Next.js Server Components.
487
1063
  // Note: Server Components don't have a real HTTP URL; this is a synthetic placeholder.
488
1064
  // Do not rely on `url` for routing, security, or network behavior.
489
- // Extended with cookies property for easier access in createContext.
1065
+ // Extended with cookies property for easier access in context factories.
490
1066
  // The json() and text() methods return empty values since there's no actual HTTP request body.
491
1067
  const req: HttpRequestLike & {
492
1068
  cookies?: { get: (key: string) => string | undefined };
@@ -507,11 +1083,10 @@ export async function createNextServer<
507
1083
  },
508
1084
  };
509
1085
 
510
- return options.createContext({
511
- req,
512
- ports: runtime.ports,
513
- });
1086
+ return runtime.createRequestContext(req);
514
1087
  },
1088
+ createRequestContext: runtime.createRequestContext,
1089
+ createServiceContext: runtime.createServiceContext,
515
1090
  contracts: runtime.contracts,
516
1091
  stop: () => runtime.stop(),
517
1092
  ports: runtime.ports,