@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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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<
|
|
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:
|
|
1051
|
+
api: createFetchHandler(runtime),
|
|
478
1052
|
route: (contract) => ({
|
|
479
|
-
handle: (fn) =>
|
|
1053
|
+
handle: (fn) => createFetchHandler(runtime.route(contract).handle(fn)),
|
|
480
1054
|
}),
|
|
481
1055
|
createContextFromNext: async () => {
|
|
482
|
-
|
|
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
|
|
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
|
|
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,
|