@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/src/index.ts
CHANGED
|
@@ -19,16 +19,20 @@ 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,
|
|
27
|
+
HttpContractConfig,
|
|
25
28
|
HttpRequestLike,
|
|
29
|
+
RawRouteInit,
|
|
26
30
|
ResolveContract,
|
|
27
31
|
RouteDef,
|
|
28
32
|
ServerInstance,
|
|
29
33
|
StandardSchemaV1,
|
|
30
34
|
} from "@beignet/core/server";
|
|
31
|
-
import { createServer } from "@beignet/core/server";
|
|
35
|
+
import { createHealthHandler, createServer } from "@beignet/core/server";
|
|
32
36
|
import type { UploadRouter } from "@beignet/core/uploads";
|
|
33
37
|
import {
|
|
34
38
|
type InferWebhookEvent,
|
|
@@ -38,7 +42,7 @@ import {
|
|
|
38
42
|
type WebhookEvent,
|
|
39
43
|
type WebhookEventSchemas,
|
|
40
44
|
} from "@beignet/core/webhooks";
|
|
41
|
-
import { createFetchHandler, toWebResponse } from "@beignet/web";
|
|
45
|
+
import { createFetchHandler, toRequestLike, toWebResponse } from "@beignet/web";
|
|
42
46
|
|
|
43
47
|
/**
|
|
44
48
|
* Server types re-exported for Next.js apps.
|
|
@@ -47,6 +51,7 @@ export type {
|
|
|
47
51
|
AnyUseCaseLike,
|
|
48
52
|
CreateServerOptions,
|
|
49
53
|
HandlerRouteDef,
|
|
54
|
+
RawRouteInit,
|
|
50
55
|
RouteDef,
|
|
51
56
|
RouteGroup,
|
|
52
57
|
RouteHook,
|
|
@@ -81,6 +86,96 @@ type AnyProvider = ServiceProvider<
|
|
|
81
86
|
any
|
|
82
87
|
>;
|
|
83
88
|
|
|
89
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
90
|
+
|
|
91
|
+
type ServerSource<TServer> = TServer | (() => MaybePromise<TServer>);
|
|
92
|
+
|
|
93
|
+
function isServerLoader<TServer>(
|
|
94
|
+
source: ServerSource<TServer>,
|
|
95
|
+
): source is () => MaybePromise<TServer> {
|
|
96
|
+
return typeof source === "function";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function resolveServerSource<TServer>(
|
|
100
|
+
source: ServerSource<TServer>,
|
|
101
|
+
): Promise<TServer> {
|
|
102
|
+
return isServerLoader(source) ? source() : source;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Unified view over `rawRoute` as exposed by a core `ServerInstance`
|
|
107
|
+
* (framework-neutral request/response) and a `NextServer` (fetch-native).
|
|
108
|
+
* Both accept a native `Request` structurally and both produce something
|
|
109
|
+
* `toWebResponse(...)` normalizes, so route factories can run their handler
|
|
110
|
+
* through either pipeline without knowing which flavor they were given.
|
|
111
|
+
*/
|
|
112
|
+
type RawRoutePipeline<Ctx> = (init: RawRouteInit) => {
|
|
113
|
+
handle: (
|
|
114
|
+
fn: (args: {
|
|
115
|
+
req: HttpRequestLike;
|
|
116
|
+
ctx: Ctx;
|
|
117
|
+
}) => MaybePromise<Response | { status: number }>,
|
|
118
|
+
) => (req: HttpRequestLike) => Promise<Response | object>;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
function rawRoutePipeline<Ctx>(
|
|
122
|
+
server: NextRouteServer<Ctx>,
|
|
123
|
+
): RawRoutePipeline<Ctx> | undefined {
|
|
124
|
+
const candidate = (server as { rawRoute?: unknown }).rawRoute;
|
|
125
|
+
return typeof candidate === "function"
|
|
126
|
+
? (candidate.bind(server) as RawRoutePipeline<Ctx>)
|
|
127
|
+
: undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Per-route-factory memo for the pipeline-built handler. The handler is
|
|
132
|
+
* rebuilt only when the resolved server instance changes (fresh server per
|
|
133
|
+
* test, hot reload), never per request.
|
|
134
|
+
*/
|
|
135
|
+
function createRawPipelineRunner<Ctx>(
|
|
136
|
+
init: RawRouteInit,
|
|
137
|
+
fn: (args: { req: HttpRequestLike; ctx: Ctx }) => Promise<Response>,
|
|
138
|
+
): (
|
|
139
|
+
server: NextRouteServer<Ctx>,
|
|
140
|
+
) => ((req: Request) => Promise<Response>) | undefined {
|
|
141
|
+
let cached:
|
|
142
|
+
| {
|
|
143
|
+
server: NextRouteServer<Ctx>;
|
|
144
|
+
handler: (req: Request) => Promise<Response>;
|
|
145
|
+
}
|
|
146
|
+
| undefined;
|
|
147
|
+
|
|
148
|
+
return (server) => {
|
|
149
|
+
const pipeline = rawRoutePipeline(server);
|
|
150
|
+
if (!pipeline) return undefined;
|
|
151
|
+
|
|
152
|
+
if (!cached || cached.server !== server) {
|
|
153
|
+
const built = pipeline(init).handle(fn);
|
|
154
|
+
cached = {
|
|
155
|
+
server,
|
|
156
|
+
handler: async (req) =>
|
|
157
|
+
toWebResponse(
|
|
158
|
+
(await built(req as unknown as HttpRequestLike)) as Parameters<
|
|
159
|
+
typeof toWebResponse
|
|
160
|
+
>[0],
|
|
161
|
+
),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return cached.handler;
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolve the native `Request` behind a pipeline request. Requests routed
|
|
171
|
+
* through `createFetchHandler` carry it as `raw`; requests passed straight
|
|
172
|
+
* into a core handler are the native request itself.
|
|
173
|
+
*/
|
|
174
|
+
function nativeRequestOf(req: HttpRequestLike): Request {
|
|
175
|
+
const raw = (req as { raw?: unknown }).raw;
|
|
176
|
+
return (raw instanceof Request ? raw : req) as Request;
|
|
177
|
+
}
|
|
178
|
+
|
|
84
179
|
/**
|
|
85
180
|
* Beignet client config with Next.js base URL defaults.
|
|
86
181
|
*/
|
|
@@ -267,14 +362,35 @@ type OutboxDrainContext = {
|
|
|
267
362
|
ports: OutboxDrainPorts;
|
|
268
363
|
};
|
|
269
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Minimal server surface required by the Next.js route factories.
|
|
367
|
+
*
|
|
368
|
+
* Both a `NextServer` and a core `ServerInstance` satisfy this shape
|
|
369
|
+
* structurally, so route modules built on these factories are executable
|
|
370
|
+
* under `bun test`: construct the route with a fake server exposing
|
|
371
|
+
* `createRequestContext` and invoke the exported handlers with a plain
|
|
372
|
+
* `Request`. No Next.js runtime (`next/headers`) is involved.
|
|
373
|
+
*
|
|
374
|
+
* When the server also exposes `rawRoute(...)` — real Beignet servers do —
|
|
375
|
+
* the factories run their handler through the full hooks pipeline (rate
|
|
376
|
+
* limiting, CORS, logging, error reporting, instrumentation). Minimal fakes
|
|
377
|
+
* without it keep the direct `createRequestContext` flow.
|
|
378
|
+
*/
|
|
379
|
+
export type NextRouteServer<Ctx> = {
|
|
380
|
+
createRequestContext: (req: HttpRequestLike) => Promise<Ctx>;
|
|
381
|
+
};
|
|
382
|
+
|
|
270
383
|
/**
|
|
271
384
|
* Options for creating a serverless-safe outbox drain route.
|
|
272
385
|
*/
|
|
273
386
|
export type CreateOutboxDrainRouteOptions<Ctx extends OutboxDrainContext> = {
|
|
274
387
|
/**
|
|
275
|
-
* Beignet
|
|
388
|
+
* Beignet server that owns app context and ports.
|
|
389
|
+
*
|
|
390
|
+
* Any object exposing `createRequestContext` works: a `NextServer`, a core
|
|
391
|
+
* `ServerInstance`, or a test fake driven from `bun test`.
|
|
276
392
|
*/
|
|
277
|
-
server:
|
|
393
|
+
server: ServerSource<NextRouteServer<Ctx>>;
|
|
278
394
|
/**
|
|
279
395
|
* Registry of events and jobs that may be delivered from the outbox.
|
|
280
396
|
*/
|
|
@@ -298,6 +414,17 @@ export type CreateOutboxDrainRouteOptions<Ctx extends OutboxDrainContext> = {
|
|
|
298
414
|
* Retry delay override passed through to `drainOutbox(...)`.
|
|
299
415
|
*/
|
|
300
416
|
retryDelayMs?: DrainOutboxOptions["retryDelayMs"];
|
|
417
|
+
/**
|
|
418
|
+
* Identity and metadata for the hooks pipeline when the server exposes
|
|
419
|
+
* `rawRoute(...)`. `metadata` feeds metadata-driven hooks such as rate
|
|
420
|
+
* limiting and idempotency; `name` and `path` identify the route to
|
|
421
|
+
* hooks, instrumentation, and devtools.
|
|
422
|
+
*/
|
|
423
|
+
pipeline?: {
|
|
424
|
+
name?: string;
|
|
425
|
+
path?: string;
|
|
426
|
+
metadata?: RawRouteInit["metadata"];
|
|
427
|
+
};
|
|
301
428
|
/**
|
|
302
429
|
* Additional response headers.
|
|
303
430
|
*/
|
|
@@ -405,9 +532,12 @@ export type CreateWebhookRouteOptions<
|
|
|
405
532
|
AllowUnknownEvents extends boolean = true,
|
|
406
533
|
> = {
|
|
407
534
|
/**
|
|
408
|
-
* Beignet
|
|
535
|
+
* Beignet server that owns app context and ports.
|
|
536
|
+
*
|
|
537
|
+
* Any object exposing `createRequestContext` works: a `NextServer`, a core
|
|
538
|
+
* `ServerInstance`, or a test fake driven from `bun test`.
|
|
409
539
|
*/
|
|
410
|
-
server:
|
|
540
|
+
server: ServerSource<NextRouteServer<Ctx>>;
|
|
411
541
|
/**
|
|
412
542
|
* Inbound webhook definition.
|
|
413
543
|
*/
|
|
@@ -433,6 +563,17 @@ export type CreateWebhookRouteOptions<
|
|
|
433
563
|
handle?: (
|
|
434
564
|
args: WebhookRouteHandlerArgs<Ctx, Webhook, AllowUnknownEvents>,
|
|
435
565
|
) => Promise<WebhookRouteHandlerResult> | WebhookRouteHandlerResult;
|
|
566
|
+
/**
|
|
567
|
+
* Identity and metadata for the hooks pipeline when the server exposes
|
|
568
|
+
* `rawRoute(...)`. `metadata` feeds metadata-driven hooks such as rate
|
|
569
|
+
* limiting and idempotency; `name` and `path` identify the route to
|
|
570
|
+
* hooks, instrumentation, and devtools.
|
|
571
|
+
*/
|
|
572
|
+
pipeline?: {
|
|
573
|
+
name?: string;
|
|
574
|
+
path?: string;
|
|
575
|
+
metadata?: RawRouteInit["metadata"];
|
|
576
|
+
};
|
|
436
577
|
/**
|
|
437
578
|
* Additional response headers.
|
|
438
579
|
*/
|
|
@@ -444,9 +585,12 @@ export type CreateWebhookRouteOptions<
|
|
|
444
585
|
*/
|
|
445
586
|
export type CreateScheduleRouteOptions<Ctx extends ScheduleRouteContext> = {
|
|
446
587
|
/**
|
|
447
|
-
* Beignet
|
|
588
|
+
* Beignet server that owns app context and ports.
|
|
589
|
+
*
|
|
590
|
+
* Any object exposing `createRequestContext` works: a `NextServer`, a core
|
|
591
|
+
* `ServerInstance`, or a test fake driven from `bun test`.
|
|
448
592
|
*/
|
|
449
|
-
server:
|
|
593
|
+
server: ServerSource<NextRouteServer<Ctx>>;
|
|
450
594
|
/**
|
|
451
595
|
* Registered schedules, usually the `schedules` array from
|
|
452
596
|
* `server/schedules.ts`.
|
|
@@ -470,6 +614,17 @@ export type CreateScheduleRouteOptions<Ctx extends ScheduleRouteContext> = {
|
|
|
470
614
|
* @default "next-cron-route"
|
|
471
615
|
*/
|
|
472
616
|
source?: string;
|
|
617
|
+
/**
|
|
618
|
+
* Identity and metadata for the hooks pipeline when the server exposes
|
|
619
|
+
* `rawRoute(...)`. `metadata` feeds metadata-driven hooks such as rate
|
|
620
|
+
* limiting and idempotency; `name` and `path` identify the route to
|
|
621
|
+
* hooks, instrumentation, and devtools.
|
|
622
|
+
*/
|
|
623
|
+
pipeline?: {
|
|
624
|
+
name?: string;
|
|
625
|
+
path?: string;
|
|
626
|
+
metadata?: RawRouteInit["metadata"];
|
|
627
|
+
};
|
|
473
628
|
/**
|
|
474
629
|
* Additional response headers.
|
|
475
630
|
*/
|
|
@@ -483,9 +638,12 @@ export type CreatePaymentWebhookRouteOptions<
|
|
|
483
638
|
Ctx extends PaymentWebhookRouteContext,
|
|
484
639
|
> = {
|
|
485
640
|
/**
|
|
486
|
-
* Beignet
|
|
641
|
+
* Beignet server that owns app context and ports.
|
|
642
|
+
*
|
|
643
|
+
* Any object exposing `createRequestContext` works: a `NextServer`, a core
|
|
644
|
+
* `ServerInstance`, or a test fake driven from `bun test`.
|
|
487
645
|
*/
|
|
488
|
-
server:
|
|
646
|
+
server: ServerSource<NextRouteServer<Ctx>>;
|
|
489
647
|
/**
|
|
490
648
|
* Provider signature header. Stripe uses `stripe-signature`.
|
|
491
649
|
*
|
|
@@ -502,6 +660,17 @@ export type CreatePaymentWebhookRouteOptions<
|
|
|
502
660
|
) =>
|
|
503
661
|
| Promise<PaymentWebhookRouteHandlerResult>
|
|
504
662
|
| PaymentWebhookRouteHandlerResult;
|
|
663
|
+
/**
|
|
664
|
+
* Identity and metadata for the hooks pipeline when the server exposes
|
|
665
|
+
* `rawRoute(...)`. `metadata` feeds metadata-driven hooks such as rate
|
|
666
|
+
* limiting and idempotency; `name` and `path` identify the route to
|
|
667
|
+
* hooks, instrumentation, and devtools.
|
|
668
|
+
*/
|
|
669
|
+
pipeline?: {
|
|
670
|
+
name?: string;
|
|
671
|
+
path?: string;
|
|
672
|
+
metadata?: RawRouteInit["metadata"];
|
|
673
|
+
};
|
|
505
674
|
/**
|
|
506
675
|
* Additional response headers.
|
|
507
676
|
*/
|
|
@@ -536,6 +705,24 @@ export interface NextServer<
|
|
|
536
705
|
fn: Handler<Ctx, ResolveContract<CLike>>,
|
|
537
706
|
) => (req: Request) => Promise<Response>;
|
|
538
707
|
};
|
|
708
|
+
/**
|
|
709
|
+
* Build a Next.js route handler for a route that cannot be a contract —
|
|
710
|
+
* webhooks, third-party auth callbacks, streaming endpoints — that still
|
|
711
|
+
* runs the whole server pipeline: hooks (rate limiting, idempotency, CORS,
|
|
712
|
+
* logging, error reporting), context creation, instrumentation, and
|
|
713
|
+
* framework error mapping.
|
|
714
|
+
*
|
|
715
|
+
* Request parsing and validation are skipped and the request body is left
|
|
716
|
+
* unconsumed, so the handler owns body reading — for example webhook
|
|
717
|
+
* signature verification over the exact raw bytes. Metadata-driven hooks
|
|
718
|
+
* read `init.metadata`. Raw routes are not added to the route registry;
|
|
719
|
+
* export the returned handler from the route file that owns the path.
|
|
720
|
+
*/
|
|
721
|
+
rawRoute: (init: RawRouteInit) => {
|
|
722
|
+
handle: (
|
|
723
|
+
fn: Handler<Ctx, HttpContractConfig>,
|
|
724
|
+
) => (req: Request) => Promise<Response>;
|
|
725
|
+
};
|
|
539
726
|
/**
|
|
540
727
|
* Create app context from `next/headers` for Server Components.
|
|
541
728
|
*/
|
|
@@ -559,6 +746,18 @@ export interface NextServer<
|
|
|
559
746
|
Ports,
|
|
560
747
|
ServiceInput
|
|
561
748
|
>["createServiceContext"];
|
|
749
|
+
/**
|
|
750
|
+
* Build a service context and run `fn` inside a scoped ambient correlation
|
|
751
|
+
* frame, returning its result.
|
|
752
|
+
*
|
|
753
|
+
* This is the script-safe counterpart to `createServiceContext(...)`.
|
|
754
|
+
* Requires `context.service` to be declared in `createNextServer(...)`.
|
|
755
|
+
*/
|
|
756
|
+
runServiceContext: ServerInstance<
|
|
757
|
+
Ctx,
|
|
758
|
+
Ports,
|
|
759
|
+
ServiceInput
|
|
760
|
+
>["runServiceContext"];
|
|
562
761
|
/**
|
|
563
762
|
* Registered contract inputs.
|
|
564
763
|
*/
|
|
@@ -573,6 +772,108 @@ export interface NextServer<
|
|
|
573
772
|
ports: Ports;
|
|
574
773
|
}
|
|
575
774
|
|
|
775
|
+
/**
|
|
776
|
+
* Lazy, memoized loader for a Beignet Next server.
|
|
777
|
+
*/
|
|
778
|
+
export type NextServerLoader<TServer> = () => Promise<TServer>;
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* HTTP method handlers for a central Beignet API catch-all route.
|
|
782
|
+
*/
|
|
783
|
+
export type NextApiRouteHandlers = {
|
|
784
|
+
DELETE: (req: Request) => Promise<Response>;
|
|
785
|
+
GET: (req: Request) => Promise<Response>;
|
|
786
|
+
HEAD: (req: Request) => Promise<Response>;
|
|
787
|
+
OPTIONS: (req: Request) => Promise<Response>;
|
|
788
|
+
PATCH: (req: Request) => Promise<Response>;
|
|
789
|
+
POST: (req: Request) => Promise<Response>;
|
|
790
|
+
PUT: (req: Request) => Promise<Response>;
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Create a memoized Next server loader for route modules.
|
|
795
|
+
*
|
|
796
|
+
* Next.js imports route modules during production builds. Exporting a lazy
|
|
797
|
+
* loader lets those modules stay cheap to import while still booting providers
|
|
798
|
+
* before the first real request in each runtime process.
|
|
799
|
+
*/
|
|
800
|
+
export function createNextServerLoader<TServer>(
|
|
801
|
+
createServer: () => MaybePromise<TServer>,
|
|
802
|
+
): NextServerLoader<TServer> {
|
|
803
|
+
let server: Promise<TServer> | undefined;
|
|
804
|
+
|
|
805
|
+
return () => {
|
|
806
|
+
if (!server) {
|
|
807
|
+
const nextServer = Promise.resolve().then(createServer);
|
|
808
|
+
server = nextServer;
|
|
809
|
+
void nextServer.catch(() => {
|
|
810
|
+
if (server === nextServer) {
|
|
811
|
+
server = undefined;
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return server;
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Create HTTP method exports for a catch-all API route.
|
|
822
|
+
*/
|
|
823
|
+
export function createApiRoute<
|
|
824
|
+
TServer extends Pick<NextServer<unknown, AnyPorts, unknown>, "api">,
|
|
825
|
+
>(loadServer: NextServerLoader<TServer>): NextApiRouteHandlers {
|
|
826
|
+
const handle = async (req: Request) => {
|
|
827
|
+
const server = await loadServer();
|
|
828
|
+
return server.api(req);
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
return {
|
|
832
|
+
DELETE: handle,
|
|
833
|
+
GET: handle,
|
|
834
|
+
HEAD: handle,
|
|
835
|
+
OPTIONS: handle,
|
|
836
|
+
PATCH: handle,
|
|
837
|
+
POST: handle,
|
|
838
|
+
PUT: handle,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Create a readiness route whose server is loaded only when the route handles
|
|
844
|
+
* a request.
|
|
845
|
+
*/
|
|
846
|
+
export function createHealthRoute<Ports extends AnyPorts>(
|
|
847
|
+
loadServer: NextServerLoader<
|
|
848
|
+
Pick<NextServer<unknown, Ports, unknown>, "ports">
|
|
849
|
+
>,
|
|
850
|
+
healthConfig: HealthConfig<Ports> | undefined,
|
|
851
|
+
environment: AppEnvironment = "production",
|
|
852
|
+
): {
|
|
853
|
+
GET: (req: Request) => Promise<Response>;
|
|
854
|
+
} {
|
|
855
|
+
return {
|
|
856
|
+
GET: async (req) => {
|
|
857
|
+
const server = await loadServer();
|
|
858
|
+
const handler = createHealthHandler(
|
|
859
|
+
server.ports,
|
|
860
|
+
healthConfig,
|
|
861
|
+
environment,
|
|
862
|
+
);
|
|
863
|
+
const response = await handler(req);
|
|
864
|
+
const headers = new Headers(response.headers);
|
|
865
|
+
if (!headers.has("cache-control")) {
|
|
866
|
+
headers.set("cache-control", "no-store");
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
return Response.json(response.body, {
|
|
870
|
+
status: response.status,
|
|
871
|
+
headers,
|
|
872
|
+
});
|
|
873
|
+
},
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
576
877
|
/**
|
|
577
878
|
* Convert a Beignet response into a native `Response`.
|
|
578
879
|
*
|
|
@@ -936,7 +1237,7 @@ export function createSwaggerUIHandler(
|
|
|
936
1237
|
* all return 404 so storage routes do not leak object existence.
|
|
937
1238
|
*/
|
|
938
1239
|
export function createStorageRoute(
|
|
939
|
-
storage: StoragePort
|
|
1240
|
+
storage: ServerSource<StoragePort>,
|
|
940
1241
|
options: CreateStorageRouteOptions = {},
|
|
941
1242
|
): {
|
|
942
1243
|
GET: (req: Request) => Promise<Response>;
|
|
@@ -945,8 +1246,22 @@ export function createStorageRoute(
|
|
|
945
1246
|
const basePath = normalizeStorageBasePath(options.basePath);
|
|
946
1247
|
|
|
947
1248
|
return {
|
|
948
|
-
GET: (req) =>
|
|
949
|
-
|
|
1249
|
+
GET: async (req) =>
|
|
1250
|
+
storageResponse(
|
|
1251
|
+
await resolveServerSource(storage),
|
|
1252
|
+
req,
|
|
1253
|
+
basePath,
|
|
1254
|
+
options,
|
|
1255
|
+
true,
|
|
1256
|
+
),
|
|
1257
|
+
HEAD: async (req) =>
|
|
1258
|
+
storageResponse(
|
|
1259
|
+
await resolveServerSource(storage),
|
|
1260
|
+
req,
|
|
1261
|
+
basePath,
|
|
1262
|
+
options,
|
|
1263
|
+
false,
|
|
1264
|
+
),
|
|
950
1265
|
};
|
|
951
1266
|
}
|
|
952
1267
|
|
|
@@ -958,7 +1273,7 @@ export function createStorageRoute(
|
|
|
958
1273
|
* `prepare`, `complete`, or `upload`.
|
|
959
1274
|
*/
|
|
960
1275
|
export function createUploadRoute(
|
|
961
|
-
router: UploadRouter
|
|
1276
|
+
router: ServerSource<UploadRouter>,
|
|
962
1277
|
options: CreateUploadRouteOptions = {},
|
|
963
1278
|
): {
|
|
964
1279
|
POST: (req: Request, ctx?: UploadRouteContext) => Promise<Response>;
|
|
@@ -999,7 +1314,9 @@ export function createUploadRoute(
|
|
|
999
1314
|
);
|
|
1000
1315
|
}
|
|
1001
1316
|
|
|
1002
|
-
|
|
1317
|
+
const resolvedRouter = await resolveServerSource(router);
|
|
1318
|
+
|
|
1319
|
+
return resolvedRouter.handleRequest(req, {
|
|
1003
1320
|
uploadName,
|
|
1004
1321
|
action,
|
|
1005
1322
|
});
|
|
@@ -1052,6 +1369,24 @@ function toHttpResponseHeaders(
|
|
|
1052
1369
|
return result;
|
|
1053
1370
|
}
|
|
1054
1371
|
|
|
1372
|
+
/**
|
|
1373
|
+
* Rebuild a request whose body was already consumed.
|
|
1374
|
+
*
|
|
1375
|
+
* Webhook routes read the raw body before anything else so signature
|
|
1376
|
+
* verification never races the context factory over one body stream. Context
|
|
1377
|
+
* creation then receives a rehydrated request carrying the same method, URL,
|
|
1378
|
+
* headers, and raw body.
|
|
1379
|
+
*/
|
|
1380
|
+
function toContextRequest(req: Request, rawBody: string): HttpRequestLike {
|
|
1381
|
+
return toRequestLike(
|
|
1382
|
+
new Request(req.url, {
|
|
1383
|
+
method: req.method,
|
|
1384
|
+
headers: req.headers,
|
|
1385
|
+
body: rawBody,
|
|
1386
|
+
}),
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1055
1390
|
function headersToRecord(headers: Headers): Record<string, string> {
|
|
1056
1391
|
const result: Record<string, string> = {};
|
|
1057
1392
|
headers.forEach((value, key) => {
|
|
@@ -1106,8 +1441,15 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
|
|
|
1106
1441
|
GET: (req: Request) => Promise<Response>;
|
|
1107
1442
|
POST: (req: Request) => Promise<Response>;
|
|
1108
1443
|
} {
|
|
1109
|
-
const
|
|
1110
|
-
|
|
1444
|
+
const drainWithContext = async ({
|
|
1445
|
+
req,
|
|
1446
|
+
ctx,
|
|
1447
|
+
}: {
|
|
1448
|
+
req: HttpRequestLike;
|
|
1449
|
+
ctx: Ctx;
|
|
1450
|
+
}): Promise<Response> => {
|
|
1451
|
+
const nativeReq = nativeRequestOf(req);
|
|
1452
|
+
const headers = resolveHeaders(options.headers, nativeReq);
|
|
1111
1453
|
const secret = options.secret;
|
|
1112
1454
|
|
|
1113
1455
|
if (!secret) {
|
|
@@ -1121,14 +1463,11 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
|
|
|
1121
1463
|
);
|
|
1122
1464
|
}
|
|
1123
1465
|
|
|
1124
|
-
if (!(await isAuthorizedCronRequest(
|
|
1466
|
+
if (!(await isAuthorizedCronRequest(nativeReq, secret))) {
|
|
1125
1467
|
return cronRouteJson({ ok: false, error: "Unauthorized" }, 401, headers);
|
|
1126
1468
|
}
|
|
1127
1469
|
|
|
1128
|
-
let ctx: Ctx | undefined;
|
|
1129
|
-
|
|
1130
1470
|
try {
|
|
1131
|
-
ctx = await options.server.createContextFromNext();
|
|
1132
1471
|
const drainCtx = ctx;
|
|
1133
1472
|
const result = await drainOutbox({
|
|
1134
1473
|
outbox: drainCtx.ports.outbox,
|
|
@@ -1183,7 +1522,7 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
|
|
|
1183
1522
|
headers,
|
|
1184
1523
|
);
|
|
1185
1524
|
} catch (error) {
|
|
1186
|
-
ctx
|
|
1525
|
+
ctx.ports.logger?.error("Outbox drain failed", { error });
|
|
1187
1526
|
|
|
1188
1527
|
return cronRouteJson(
|
|
1189
1528
|
{
|
|
@@ -1196,6 +1535,69 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
|
|
|
1196
1535
|
}
|
|
1197
1536
|
};
|
|
1198
1537
|
|
|
1538
|
+
const pipelineInit = {
|
|
1539
|
+
name: options.pipeline?.name ?? "outbox.drain",
|
|
1540
|
+
path: options.pipeline?.path ?? "/outbox/drain",
|
|
1541
|
+
metadata: options.pipeline?.metadata,
|
|
1542
|
+
};
|
|
1543
|
+
const pipelineRunners = {
|
|
1544
|
+
GET: createRawPipelineRunner<Ctx>(
|
|
1545
|
+
{ ...pipelineInit, method: "GET" },
|
|
1546
|
+
drainWithContext,
|
|
1547
|
+
),
|
|
1548
|
+
POST: createRawPipelineRunner<Ctx>(
|
|
1549
|
+
{ ...pipelineInit, method: "POST" },
|
|
1550
|
+
drainWithContext,
|
|
1551
|
+
),
|
|
1552
|
+
};
|
|
1553
|
+
|
|
1554
|
+
const drainPendingOutbox = async (req: Request): Promise<Response> => {
|
|
1555
|
+
const server = await resolveServerSource(options.server);
|
|
1556
|
+
|
|
1557
|
+
const runner =
|
|
1558
|
+
pipelineRunners[req.method.toUpperCase() === "GET" ? "GET" : "POST"];
|
|
1559
|
+
const pipelined = runner(server);
|
|
1560
|
+
if (pipelined) return pipelined(req);
|
|
1561
|
+
|
|
1562
|
+
// Minimal servers without rawRoute(...) — usually test fakes — keep the
|
|
1563
|
+
// original flow: authorize before touching context, then drain inline.
|
|
1564
|
+
const headers = resolveHeaders(options.headers, req);
|
|
1565
|
+
const secret = options.secret;
|
|
1566
|
+
|
|
1567
|
+
if (!secret) {
|
|
1568
|
+
return cronRouteJson(
|
|
1569
|
+
{
|
|
1570
|
+
ok: false,
|
|
1571
|
+
error: "CRON_SECRET is not configured.",
|
|
1572
|
+
},
|
|
1573
|
+
500,
|
|
1574
|
+
headers,
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
if (!(await isAuthorizedCronRequest(req, secret))) {
|
|
1579
|
+
return cronRouteJson({ ok: false, error: "Unauthorized" }, 401, headers);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
let ctx: Ctx;
|
|
1583
|
+
try {
|
|
1584
|
+
ctx = await server.createRequestContext(toRequestLike(req));
|
|
1585
|
+
} catch (error) {
|
|
1586
|
+
console.error("Outbox drain context failed", { error });
|
|
1587
|
+
|
|
1588
|
+
return cronRouteJson(
|
|
1589
|
+
{
|
|
1590
|
+
ok: false,
|
|
1591
|
+
error: "Outbox drain failed.",
|
|
1592
|
+
},
|
|
1593
|
+
500,
|
|
1594
|
+
headers,
|
|
1595
|
+
);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
return drainWithContext({ req: toRequestLike(req), ctx });
|
|
1599
|
+
};
|
|
1600
|
+
|
|
1199
1601
|
return {
|
|
1200
1602
|
GET: drainPendingOutbox,
|
|
1201
1603
|
POST: drainPendingOutbox,
|
|
@@ -1206,9 +1608,10 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
|
|
|
1206
1608
|
* Create a Next.js POST handler for generic verified inbound webhooks.
|
|
1207
1609
|
*
|
|
1208
1610
|
* The route reads the raw request body as text, normalizes request headers,
|
|
1209
|
-
*
|
|
1210
|
-
*
|
|
1211
|
-
*
|
|
1611
|
+
* builds app context from the real request through
|
|
1612
|
+
* `server.createRequestContext(...)`, verifies the event through the webhook
|
|
1613
|
+
* definition or a context-aware verifier, validates the matching event
|
|
1614
|
+
* payload schema, and delegates fulfillment to app code.
|
|
1212
1615
|
*/
|
|
1213
1616
|
export function createWebhookRoute<
|
|
1214
1617
|
Ctx extends WebhookRouteContext,
|
|
@@ -1219,130 +1622,184 @@ export function createWebhookRoute<
|
|
|
1219
1622
|
): {
|
|
1220
1623
|
POST: (req: Request) => Promise<Response>;
|
|
1221
1624
|
} {
|
|
1625
|
+
const handleWebhookRequest = async ({
|
|
1626
|
+
req,
|
|
1627
|
+
ctx,
|
|
1628
|
+
}: {
|
|
1629
|
+
req: HttpRequestLike;
|
|
1630
|
+
ctx: Ctx;
|
|
1631
|
+
}): Promise<Response> => {
|
|
1632
|
+
const nativeReq = nativeRequestOf(req);
|
|
1633
|
+
const headers = resolveHeaders(options.headers, nativeReq);
|
|
1634
|
+
const requestHeaders = headersToRecord(new Headers(req.headers));
|
|
1635
|
+
|
|
1636
|
+
let rawBody: string;
|
|
1637
|
+
|
|
1638
|
+
try {
|
|
1639
|
+
rawBody = await req.text();
|
|
1640
|
+
} catch {
|
|
1641
|
+
return webhookRouteJson(
|
|
1642
|
+
{
|
|
1643
|
+
ok: false,
|
|
1644
|
+
error: "Webhook body read failed.",
|
|
1645
|
+
},
|
|
1646
|
+
400,
|
|
1647
|
+
headers,
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
let event: WebhookRouteHandlerArgs<
|
|
1652
|
+
Ctx,
|
|
1653
|
+
Webhook,
|
|
1654
|
+
AllowUnknownEvents
|
|
1655
|
+
>["event"];
|
|
1656
|
+
|
|
1657
|
+
try {
|
|
1658
|
+
const input: VerifyWebhookInput = {
|
|
1659
|
+
rawBody,
|
|
1660
|
+
headers: requestHeaders,
|
|
1661
|
+
receivedAt: new Date(),
|
|
1662
|
+
};
|
|
1663
|
+
const routeVerifier = options.verify;
|
|
1664
|
+
const verifier = routeVerifier
|
|
1665
|
+
? {
|
|
1666
|
+
async verify() {
|
|
1667
|
+
return routeVerifier({
|
|
1668
|
+
req: nativeReq,
|
|
1669
|
+
ctx,
|
|
1670
|
+
rawBody,
|
|
1671
|
+
headers: requestHeaders,
|
|
1672
|
+
input,
|
|
1673
|
+
});
|
|
1674
|
+
},
|
|
1675
|
+
}
|
|
1676
|
+
: undefined;
|
|
1677
|
+
|
|
1678
|
+
event = (await verifyWebhook(options.webhook, input, {
|
|
1679
|
+
verifier,
|
|
1680
|
+
allowUnknownEvents: options.allowUnknownEvents,
|
|
1681
|
+
})) as WebhookRouteHandlerArgs<Ctx, Webhook, AllowUnknownEvents>["event"];
|
|
1682
|
+
} catch (error) {
|
|
1683
|
+
ctx.ports.logger?.error("Webhook verification failed", {
|
|
1684
|
+
error,
|
|
1685
|
+
webhookName: options.webhook.name,
|
|
1686
|
+
provider: options.webhook.provider,
|
|
1687
|
+
requestId: ctx.requestId,
|
|
1688
|
+
traceId: ctx.traceId,
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
return webhookRouteJson(
|
|
1692
|
+
{
|
|
1693
|
+
ok: false,
|
|
1694
|
+
error: "Webhook verification failed.",
|
|
1695
|
+
},
|
|
1696
|
+
400,
|
|
1697
|
+
headers,
|
|
1698
|
+
);
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
try {
|
|
1702
|
+
const result = await options.handle?.({
|
|
1703
|
+
req: nativeReq,
|
|
1704
|
+
ctx,
|
|
1705
|
+
rawBody,
|
|
1706
|
+
event,
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
if (result instanceof Response) return result;
|
|
1710
|
+
|
|
1711
|
+
if (result) {
|
|
1712
|
+
return toWebResponse({
|
|
1713
|
+
status: result.status ?? 200,
|
|
1714
|
+
headers: toHttpResponseHeaders(result.headers ?? headers),
|
|
1715
|
+
body: result.body,
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
return toWebResponse({
|
|
1720
|
+
status: 200,
|
|
1721
|
+
headers: toHttpResponseHeaders(headers),
|
|
1722
|
+
body: {
|
|
1723
|
+
ok: true,
|
|
1724
|
+
webhookName: options.webhook.name,
|
|
1725
|
+
eventId: event.id,
|
|
1726
|
+
eventType: event.type,
|
|
1727
|
+
},
|
|
1728
|
+
});
|
|
1729
|
+
} catch (error) {
|
|
1730
|
+
ctx.ports.logger?.error("Webhook handler failed", {
|
|
1731
|
+
error,
|
|
1732
|
+
webhookName: options.webhook.name,
|
|
1733
|
+
provider: options.webhook.provider,
|
|
1734
|
+
requestId: ctx.requestId,
|
|
1735
|
+
traceId: ctx.traceId,
|
|
1736
|
+
eventId: event.id,
|
|
1737
|
+
eventType: event.type,
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
return webhookRouteJson(
|
|
1741
|
+
{
|
|
1742
|
+
ok: false,
|
|
1743
|
+
error: "Webhook handler failed.",
|
|
1744
|
+
},
|
|
1745
|
+
500,
|
|
1746
|
+
headers,
|
|
1747
|
+
);
|
|
1748
|
+
}
|
|
1749
|
+
};
|
|
1750
|
+
|
|
1751
|
+
const runPipeline = createRawPipelineRunner<Ctx>(
|
|
1752
|
+
{
|
|
1753
|
+
name: options.pipeline?.name ?? `webhook.${options.webhook.name}`,
|
|
1754
|
+
method: "POST",
|
|
1755
|
+
path: options.pipeline?.path ?? `/webhooks/${options.webhook.name}`,
|
|
1756
|
+
metadata: options.pipeline?.metadata,
|
|
1757
|
+
},
|
|
1758
|
+
handleWebhookRequest,
|
|
1759
|
+
);
|
|
1760
|
+
|
|
1222
1761
|
return {
|
|
1223
1762
|
POST: async (req) => {
|
|
1224
|
-
const
|
|
1225
|
-
const requestHeaders = headersToRecord(req.headers);
|
|
1763
|
+
const server = await resolveServerSource(options.server);
|
|
1226
1764
|
|
|
1227
|
-
|
|
1765
|
+
const pipelined = runPipeline(server);
|
|
1766
|
+
if (pipelined) return pipelined(req);
|
|
1228
1767
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
return webhookRouteJson(
|
|
1233
|
-
{
|
|
1234
|
-
ok: false,
|
|
1235
|
-
error: "Webhook context failed.",
|
|
1236
|
-
},
|
|
1237
|
-
500,
|
|
1238
|
-
headers,
|
|
1239
|
-
);
|
|
1240
|
-
}
|
|
1768
|
+
// Minimal servers without rawRoute(...) — usually test fakes — build
|
|
1769
|
+
// context directly and run the same handler outside the hooks pipeline.
|
|
1770
|
+
const headers = resolveHeaders(options.headers, req);
|
|
1241
1771
|
|
|
1242
1772
|
let rawBody: string;
|
|
1243
|
-
let event: WebhookRouteHandlerArgs<
|
|
1244
|
-
Ctx,
|
|
1245
|
-
Webhook,
|
|
1246
|
-
AllowUnknownEvents
|
|
1247
|
-
>["event"];
|
|
1248
|
-
|
|
1249
1773
|
try {
|
|
1250
1774
|
rawBody = await req.text();
|
|
1251
|
-
|
|
1252
|
-
rawBody,
|
|
1253
|
-
headers: requestHeaders,
|
|
1254
|
-
receivedAt: new Date(),
|
|
1255
|
-
};
|
|
1256
|
-
const routeVerifier = options.verify;
|
|
1257
|
-
const verifier = routeVerifier
|
|
1258
|
-
? {
|
|
1259
|
-
async verify() {
|
|
1260
|
-
return routeVerifier({
|
|
1261
|
-
req,
|
|
1262
|
-
ctx,
|
|
1263
|
-
rawBody,
|
|
1264
|
-
headers: requestHeaders,
|
|
1265
|
-
input,
|
|
1266
|
-
});
|
|
1267
|
-
},
|
|
1268
|
-
}
|
|
1269
|
-
: undefined;
|
|
1270
|
-
|
|
1271
|
-
event = (await verifyWebhook(options.webhook, input, {
|
|
1272
|
-
verifier,
|
|
1273
|
-
allowUnknownEvents: options.allowUnknownEvents,
|
|
1274
|
-
})) as WebhookRouteHandlerArgs<
|
|
1275
|
-
Ctx,
|
|
1276
|
-
Webhook,
|
|
1277
|
-
AllowUnknownEvents
|
|
1278
|
-
>["event"];
|
|
1279
|
-
} catch (error) {
|
|
1280
|
-
ctx.ports.logger?.error("Webhook verification failed", {
|
|
1281
|
-
error,
|
|
1282
|
-
webhookName: options.webhook.name,
|
|
1283
|
-
provider: options.webhook.provider,
|
|
1284
|
-
requestId: ctx.requestId,
|
|
1285
|
-
traceId: ctx.traceId,
|
|
1286
|
-
});
|
|
1287
|
-
|
|
1775
|
+
} catch {
|
|
1288
1776
|
return webhookRouteJson(
|
|
1289
1777
|
{
|
|
1290
1778
|
ok: false,
|
|
1291
|
-
error: "Webhook
|
|
1779
|
+
error: "Webhook body read failed.",
|
|
1292
1780
|
},
|
|
1293
1781
|
400,
|
|
1294
1782
|
headers,
|
|
1295
1783
|
);
|
|
1296
1784
|
}
|
|
1297
1785
|
|
|
1298
|
-
|
|
1299
|
-
const result = await options.handle?.({
|
|
1300
|
-
req,
|
|
1301
|
-
ctx,
|
|
1302
|
-
rawBody,
|
|
1303
|
-
event,
|
|
1304
|
-
});
|
|
1305
|
-
|
|
1306
|
-
if (result instanceof Response) return result;
|
|
1307
|
-
|
|
1308
|
-
if (result) {
|
|
1309
|
-
return toWebResponse({
|
|
1310
|
-
status: result.status ?? 200,
|
|
1311
|
-
headers: toHttpResponseHeaders(result.headers ?? headers),
|
|
1312
|
-
body: result.body,
|
|
1313
|
-
});
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
return toWebResponse({
|
|
1317
|
-
status: 200,
|
|
1318
|
-
headers: toHttpResponseHeaders(headers),
|
|
1319
|
-
body: {
|
|
1320
|
-
ok: true,
|
|
1321
|
-
webhookName: options.webhook.name,
|
|
1322
|
-
eventId: event.id,
|
|
1323
|
-
eventType: event.type,
|
|
1324
|
-
},
|
|
1325
|
-
});
|
|
1326
|
-
} catch (error) {
|
|
1327
|
-
ctx.ports.logger?.error("Webhook handler failed", {
|
|
1328
|
-
error,
|
|
1329
|
-
webhookName: options.webhook.name,
|
|
1330
|
-
provider: options.webhook.provider,
|
|
1331
|
-
requestId: ctx.requestId,
|
|
1332
|
-
traceId: ctx.traceId,
|
|
1333
|
-
eventId: event.id,
|
|
1334
|
-
eventType: event.type,
|
|
1335
|
-
});
|
|
1786
|
+
const contextRequest = toContextRequest(req, rawBody);
|
|
1336
1787
|
|
|
1788
|
+
let ctx: Ctx;
|
|
1789
|
+
try {
|
|
1790
|
+
ctx = await server.createRequestContext(contextRequest);
|
|
1791
|
+
} catch {
|
|
1337
1792
|
return webhookRouteJson(
|
|
1338
1793
|
{
|
|
1339
1794
|
ok: false,
|
|
1340
|
-
error: "Webhook
|
|
1795
|
+
error: "Webhook context failed.",
|
|
1341
1796
|
},
|
|
1342
1797
|
500,
|
|
1343
1798
|
headers,
|
|
1344
1799
|
);
|
|
1345
1800
|
}
|
|
1801
|
+
|
|
1802
|
+
return handleWebhookRequest({ req: toContextRequest(req, rawBody), ctx });
|
|
1346
1803
|
},
|
|
1347
1804
|
};
|
|
1348
1805
|
}
|
|
@@ -1350,9 +1807,10 @@ export function createWebhookRoute<
|
|
|
1350
1807
|
/**
|
|
1351
1808
|
* Create a Next.js POST handler for provider-verified payment webhooks.
|
|
1352
1809
|
*
|
|
1353
|
-
* The route
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1810
|
+
* The route extracts the provider signature header, reads the raw request
|
|
1811
|
+
* body as text, builds app context from the real request through
|
|
1812
|
+
* `server.createRequestContext(...)`, verifies the event through
|
|
1813
|
+
* `ctx.ports.payments`, and then delegates fulfillment to app code. This is the canonical helper for Beignet
|
|
1356
1814
|
* billing flows backed by `PaymentsPort`. Keep this route on the Node.js
|
|
1357
1815
|
* runtime when your provider SDK requires Node APIs.
|
|
1358
1816
|
*/
|
|
@@ -1365,12 +1823,138 @@ export function createPaymentWebhookRoute<
|
|
|
1365
1823
|
} {
|
|
1366
1824
|
const signatureHeader = options.signatureHeader ?? "stripe-signature";
|
|
1367
1825
|
|
|
1826
|
+
const handlePaymentWebhook = async ({
|
|
1827
|
+
req,
|
|
1828
|
+
ctx,
|
|
1829
|
+
}: {
|
|
1830
|
+
req: HttpRequestLike;
|
|
1831
|
+
ctx: Ctx;
|
|
1832
|
+
}): Promise<Response> => {
|
|
1833
|
+
const nativeReq = nativeRequestOf(req);
|
|
1834
|
+
const headers = resolveHeaders(options.headers, nativeReq);
|
|
1835
|
+
const signature = new Headers(req.headers).get(signatureHeader);
|
|
1836
|
+
|
|
1837
|
+
if (!signature) {
|
|
1838
|
+
return paymentWebhookJson(
|
|
1839
|
+
{
|
|
1840
|
+
ok: false,
|
|
1841
|
+
error: `Missing ${signatureHeader} header.`,
|
|
1842
|
+
},
|
|
1843
|
+
400,
|
|
1844
|
+
headers,
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
let rawBody: string;
|
|
1849
|
+
|
|
1850
|
+
try {
|
|
1851
|
+
rawBody = await req.text();
|
|
1852
|
+
} catch {
|
|
1853
|
+
return paymentWebhookJson(
|
|
1854
|
+
{
|
|
1855
|
+
ok: false,
|
|
1856
|
+
error: "Payment webhook body read failed.",
|
|
1857
|
+
},
|
|
1858
|
+
400,
|
|
1859
|
+
headers,
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
let event: PaymentWebhookEvent;
|
|
1864
|
+
|
|
1865
|
+
try {
|
|
1866
|
+
event = await ctx.ports.payments.verifyWebhook({
|
|
1867
|
+
rawBody,
|
|
1868
|
+
signature,
|
|
1869
|
+
});
|
|
1870
|
+
} catch (error) {
|
|
1871
|
+
ctx.ports.logger?.error("Payment webhook verification failed", {
|
|
1872
|
+
error,
|
|
1873
|
+
requestId: ctx.requestId,
|
|
1874
|
+
traceId: ctx.traceId,
|
|
1875
|
+
});
|
|
1876
|
+
|
|
1877
|
+
return paymentWebhookJson(
|
|
1878
|
+
{
|
|
1879
|
+
ok: false,
|
|
1880
|
+
error: "Payment webhook verification failed.",
|
|
1881
|
+
},
|
|
1882
|
+
400,
|
|
1883
|
+
headers,
|
|
1884
|
+
);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
try {
|
|
1888
|
+
const result = await options.handle?.({
|
|
1889
|
+
req: nativeReq,
|
|
1890
|
+
ctx,
|
|
1891
|
+
rawBody,
|
|
1892
|
+
signature,
|
|
1893
|
+
event,
|
|
1894
|
+
});
|
|
1895
|
+
|
|
1896
|
+
if (result instanceof Response) return result;
|
|
1897
|
+
|
|
1898
|
+
if (result) {
|
|
1899
|
+
return toWebResponse({
|
|
1900
|
+
status: result.status ?? 200,
|
|
1901
|
+
headers: toHttpResponseHeaders(result.headers ?? headers),
|
|
1902
|
+
body: result.body,
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
return toWebResponse({
|
|
1907
|
+
status: 200,
|
|
1908
|
+
headers: toHttpResponseHeaders(headers),
|
|
1909
|
+
body: {
|
|
1910
|
+
ok: true,
|
|
1911
|
+
eventId: event.id,
|
|
1912
|
+
eventType: event.type,
|
|
1913
|
+
},
|
|
1914
|
+
});
|
|
1915
|
+
} catch (error) {
|
|
1916
|
+
ctx.ports.logger?.error("Payment webhook handler failed", {
|
|
1917
|
+
error,
|
|
1918
|
+
requestId: ctx.requestId,
|
|
1919
|
+
traceId: ctx.traceId,
|
|
1920
|
+
eventId: event.id,
|
|
1921
|
+
eventType: event.type,
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
return paymentWebhookJson(
|
|
1925
|
+
{
|
|
1926
|
+
ok: false,
|
|
1927
|
+
error: "Payment webhook handler failed.",
|
|
1928
|
+
},
|
|
1929
|
+
500,
|
|
1930
|
+
headers,
|
|
1931
|
+
);
|
|
1932
|
+
}
|
|
1933
|
+
};
|
|
1934
|
+
|
|
1935
|
+
const runPipeline = createRawPipelineRunner<Ctx>(
|
|
1936
|
+
{
|
|
1937
|
+
name: options.pipeline?.name ?? "webhook.payments",
|
|
1938
|
+
method: "POST",
|
|
1939
|
+
path: options.pipeline?.path ?? "/webhooks/payments",
|
|
1940
|
+
metadata: options.pipeline?.metadata,
|
|
1941
|
+
},
|
|
1942
|
+
handlePaymentWebhook,
|
|
1943
|
+
);
|
|
1944
|
+
|
|
1368
1945
|
return {
|
|
1369
1946
|
POST: async (req) => {
|
|
1947
|
+
const server = await resolveServerSource(options.server);
|
|
1948
|
+
|
|
1949
|
+
const pipelined = runPipeline(server);
|
|
1950
|
+
if (pipelined) return pipelined(req);
|
|
1951
|
+
|
|
1952
|
+
// Minimal servers without rawRoute(...) — usually test fakes — build
|
|
1953
|
+
// context directly and run the same handler outside the hooks pipeline.
|
|
1954
|
+
// The signature gate runs first so no context is created without one.
|
|
1370
1955
|
const headers = resolveHeaders(options.headers, req);
|
|
1371
|
-
const signature = req.headers.get(signatureHeader);
|
|
1372
1956
|
|
|
1373
|
-
if (!
|
|
1957
|
+
if (!req.headers.get(signatureHeader)) {
|
|
1374
1958
|
return paymentWebhookJson(
|
|
1375
1959
|
{
|
|
1376
1960
|
ok: false,
|
|
@@ -1381,93 +1965,35 @@ export function createPaymentWebhookRoute<
|
|
|
1381
1965
|
);
|
|
1382
1966
|
}
|
|
1383
1967
|
|
|
1384
|
-
let ctx: Ctx;
|
|
1385
|
-
|
|
1386
|
-
try {
|
|
1387
|
-
ctx = await options.server.createContextFromNext();
|
|
1388
|
-
} catch {
|
|
1389
|
-
return paymentWebhookJson(
|
|
1390
|
-
{
|
|
1391
|
-
ok: false,
|
|
1392
|
-
error: "Payment webhook context failed.",
|
|
1393
|
-
},
|
|
1394
|
-
500,
|
|
1395
|
-
headers,
|
|
1396
|
-
);
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
1968
|
let rawBody: string;
|
|
1400
|
-
let event: PaymentWebhookEvent;
|
|
1401
|
-
|
|
1402
1969
|
try {
|
|
1403
1970
|
rawBody = await req.text();
|
|
1404
|
-
|
|
1405
|
-
rawBody,
|
|
1406
|
-
signature,
|
|
1407
|
-
});
|
|
1408
|
-
} catch (error) {
|
|
1409
|
-
ctx.ports.logger?.error("Payment webhook verification failed", {
|
|
1410
|
-
error,
|
|
1411
|
-
requestId: ctx.requestId,
|
|
1412
|
-
traceId: ctx.traceId,
|
|
1413
|
-
});
|
|
1414
|
-
|
|
1971
|
+
} catch {
|
|
1415
1972
|
return paymentWebhookJson(
|
|
1416
1973
|
{
|
|
1417
1974
|
ok: false,
|
|
1418
|
-
error: "Payment webhook
|
|
1975
|
+
error: "Payment webhook body read failed.",
|
|
1419
1976
|
},
|
|
1420
1977
|
400,
|
|
1421
1978
|
headers,
|
|
1422
1979
|
);
|
|
1423
1980
|
}
|
|
1424
1981
|
|
|
1982
|
+
let ctx: Ctx;
|
|
1425
1983
|
try {
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
ctx,
|
|
1429
|
-
rawBody,
|
|
1430
|
-
signature,
|
|
1431
|
-
event,
|
|
1432
|
-
});
|
|
1433
|
-
|
|
1434
|
-
if (result instanceof Response) return result;
|
|
1435
|
-
|
|
1436
|
-
if (result) {
|
|
1437
|
-
return toWebResponse({
|
|
1438
|
-
status: result.status ?? 200,
|
|
1439
|
-
headers: toHttpResponseHeaders(result.headers ?? headers),
|
|
1440
|
-
body: result.body,
|
|
1441
|
-
});
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
return toWebResponse({
|
|
1445
|
-
status: 200,
|
|
1446
|
-
headers: toHttpResponseHeaders(headers),
|
|
1447
|
-
body: {
|
|
1448
|
-
ok: true,
|
|
1449
|
-
eventId: event.id,
|
|
1450
|
-
eventType: event.type,
|
|
1451
|
-
},
|
|
1452
|
-
});
|
|
1453
|
-
} catch (error) {
|
|
1454
|
-
ctx.ports.logger?.error("Payment webhook handler failed", {
|
|
1455
|
-
error,
|
|
1456
|
-
requestId: ctx.requestId,
|
|
1457
|
-
traceId: ctx.traceId,
|
|
1458
|
-
eventId: event.id,
|
|
1459
|
-
eventType: event.type,
|
|
1460
|
-
});
|
|
1461
|
-
|
|
1984
|
+
ctx = await server.createRequestContext(toContextRequest(req, rawBody));
|
|
1985
|
+
} catch {
|
|
1462
1986
|
return paymentWebhookJson(
|
|
1463
1987
|
{
|
|
1464
1988
|
ok: false,
|
|
1465
|
-
error: "Payment webhook
|
|
1989
|
+
error: "Payment webhook context failed.",
|
|
1466
1990
|
},
|
|
1467
1991
|
500,
|
|
1468
1992
|
headers,
|
|
1469
1993
|
);
|
|
1470
1994
|
}
|
|
1995
|
+
|
|
1996
|
+
return handlePaymentWebhook({ req: toContextRequest(req, rawBody), ctx });
|
|
1471
1997
|
},
|
|
1472
1998
|
};
|
|
1473
1999
|
}
|
|
@@ -1476,8 +2002,9 @@ export function createPaymentWebhookRoute<
|
|
|
1476
2002
|
* Create Next.js route handlers that trigger one registered schedule.
|
|
1477
2003
|
*
|
|
1478
2004
|
* This helper is intended for serverless cron routes. It authenticates the
|
|
1479
|
-
* caller with a timing-safe bearer comparison,
|
|
1480
|
-
*
|
|
2005
|
+
* caller with a timing-safe bearer comparison, builds app context from the
|
|
2006
|
+
* real request through `server.createRequestContext(...)`, runs the schedule
|
|
2007
|
+
* inline, and records `schedule` events through the resolved
|
|
1481
2008
|
* provider instrumentation port (`ports.instrumentation`, then
|
|
1482
2009
|
* `ports.devtools`) when one is installed.
|
|
1483
2010
|
*/
|
|
@@ -1503,8 +2030,15 @@ export function createScheduleRoute<Ctx extends ScheduleRouteContext>(
|
|
|
1503
2030
|
);
|
|
1504
2031
|
}
|
|
1505
2032
|
|
|
1506
|
-
const
|
|
1507
|
-
|
|
2033
|
+
const runSchedule = async ({
|
|
2034
|
+
req,
|
|
2035
|
+
ctx,
|
|
2036
|
+
}: {
|
|
2037
|
+
req: HttpRequestLike;
|
|
2038
|
+
ctx: Ctx;
|
|
2039
|
+
}): Promise<Response> => {
|
|
2040
|
+
const nativeReq = nativeRequestOf(req);
|
|
2041
|
+
const headers = resolveHeaders(options.headers, nativeReq);
|
|
1508
2042
|
const secret = options.secret;
|
|
1509
2043
|
|
|
1510
2044
|
if (!secret) {
|
|
@@ -1519,12 +2053,11 @@ export function createScheduleRoute<Ctx extends ScheduleRouteContext>(
|
|
|
1519
2053
|
);
|
|
1520
2054
|
}
|
|
1521
2055
|
|
|
1522
|
-
if (!(await isAuthorizedCronRequest(
|
|
2056
|
+
if (!(await isAuthorizedCronRequest(nativeReq, secret))) {
|
|
1523
2057
|
return cronRouteJson({ ok: false, error: "Unauthorized" }, 401, headers);
|
|
1524
2058
|
}
|
|
1525
2059
|
|
|
1526
2060
|
try {
|
|
1527
|
-
const ctx = await options.server.createContextFromNext();
|
|
1528
2061
|
const runner = createInlineScheduleRunner<Ctx>({
|
|
1529
2062
|
ctx,
|
|
1530
2063
|
instrumentation: resolveProviderInstrumentationPort(ctx.ports),
|
|
@@ -1565,6 +2098,69 @@ export function createScheduleRoute<Ctx extends ScheduleRouteContext>(
|
|
|
1565
2098
|
}
|
|
1566
2099
|
};
|
|
1567
2100
|
|
|
2101
|
+
const pipelineInit = {
|
|
2102
|
+
name: options.pipeline?.name ?? `schedule.${schedule.name}`,
|
|
2103
|
+
path: options.pipeline?.path ?? `/schedules/${schedule.name}`,
|
|
2104
|
+
metadata: options.pipeline?.metadata,
|
|
2105
|
+
};
|
|
2106
|
+
const pipelineRunners = {
|
|
2107
|
+
GET: createRawPipelineRunner<Ctx>(
|
|
2108
|
+
{ ...pipelineInit, method: "GET" },
|
|
2109
|
+
runSchedule,
|
|
2110
|
+
),
|
|
2111
|
+
POST: createRawPipelineRunner<Ctx>(
|
|
2112
|
+
{ ...pipelineInit, method: "POST" },
|
|
2113
|
+
runSchedule,
|
|
2114
|
+
),
|
|
2115
|
+
};
|
|
2116
|
+
|
|
2117
|
+
const runScheduleFromRequest = async (req: Request): Promise<Response> => {
|
|
2118
|
+
const server = await resolveServerSource(options.server);
|
|
2119
|
+
|
|
2120
|
+
const runner =
|
|
2121
|
+
pipelineRunners[req.method.toUpperCase() === "GET" ? "GET" : "POST"];
|
|
2122
|
+
const pipelined = runner(server);
|
|
2123
|
+
if (pipelined) return pipelined(req);
|
|
2124
|
+
|
|
2125
|
+
// Minimal servers without rawRoute(...) — usually test fakes — keep the
|
|
2126
|
+
// original flow: authorize before touching context, then run inline.
|
|
2127
|
+
const headers = resolveHeaders(options.headers, req);
|
|
2128
|
+
const secret = options.secret;
|
|
2129
|
+
|
|
2130
|
+
if (!secret) {
|
|
2131
|
+
return cronRouteJson(
|
|
2132
|
+
{
|
|
2133
|
+
ok: false,
|
|
2134
|
+
error: "CRON_SECRET is not configured.",
|
|
2135
|
+
scheduleName: schedule.name,
|
|
2136
|
+
},
|
|
2137
|
+
500,
|
|
2138
|
+
headers,
|
|
2139
|
+
);
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
if (!(await isAuthorizedCronRequest(req, secret))) {
|
|
2143
|
+
return cronRouteJson({ ok: false, error: "Unauthorized" }, 401, headers);
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
let ctx: Ctx;
|
|
2147
|
+
try {
|
|
2148
|
+
ctx = await server.createRequestContext(toRequestLike(req));
|
|
2149
|
+
} catch {
|
|
2150
|
+
return cronRouteJson(
|
|
2151
|
+
{
|
|
2152
|
+
ok: false,
|
|
2153
|
+
error: "Schedule failed.",
|
|
2154
|
+
scheduleName: schedule.name,
|
|
2155
|
+
},
|
|
2156
|
+
500,
|
|
2157
|
+
headers,
|
|
2158
|
+
);
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
return runSchedule({ req: toRequestLike(req), ctx });
|
|
2162
|
+
};
|
|
2163
|
+
|
|
1568
2164
|
return {
|
|
1569
2165
|
GET: runScheduleFromRequest,
|
|
1570
2166
|
POST: runScheduleFromRequest,
|
|
@@ -1596,6 +2192,9 @@ export async function createNextServer<
|
|
|
1596
2192
|
route: (contract) => ({
|
|
1597
2193
|
handle: (fn) => createFetchHandler(runtime.route(contract).handle(fn)),
|
|
1598
2194
|
}),
|
|
2195
|
+
rawRoute: (init) => ({
|
|
2196
|
+
handle: (fn) => createFetchHandler(runtime.rawRoute(init).handle(fn)),
|
|
2197
|
+
}),
|
|
1599
2198
|
createContextFromNext: async () => {
|
|
1600
2199
|
// "next" ships no package exports map, so Node-style ESM resolution
|
|
1601
2200
|
// (NodeNext) needs the explicit .js subpath. Bundlers resolve it the same.
|
|
@@ -1641,6 +2240,7 @@ export async function createNextServer<
|
|
|
1641
2240
|
},
|
|
1642
2241
|
createRequestContext: runtime.createRequestContext,
|
|
1643
2242
|
createServiceContext: runtime.createServiceContext,
|
|
2243
|
+
runServiceContext: runtime.runServiceContext,
|
|
1644
2244
|
contracts: runtime.contracts,
|
|
1645
2245
|
stop: () => runtime.stop(),
|
|
1646
2246
|
ports: runtime.ports,
|