@beignet/next 0.0.27 → 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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # @beignet/next
2
2
 
3
+ ## 0.0.28
4
+
5
+ ### Patch Changes
6
+
7
+ - 57504c5: Add `server.rawRoute(...)`: build handlers for routes that cannot be contracts — third-party callbacks, signature-verified webhooks, streaming endpoints — that still run the full pipeline (hooks, context creation, instrumentation, framework error mapping) with the request body left unconsumed. The Next.js webhook, payment webhook, schedule, and outbox drain route factories now run through this pipeline automatically when the server exposes `rawRoute`, with a new `pipeline` option for route identity and hook metadata.
8
+ - Updated dependencies [f21e8af]
9
+ - Updated dependencies [57504c5]
10
+ - @beignet/web@0.0.28
11
+
3
12
  ## 0.0.27
4
13
 
5
14
  ### Patch Changes
package/README.md CHANGED
@@ -316,6 +316,52 @@ export async function GET(req: Request) {
316
316
  }
317
317
  ```
318
318
 
319
+ #### `server.rawRoute(init)`
320
+
321
+ Builds a handler for a route that cannot be a contract — third-party callback
322
+ endpoints with externally defined request shapes, signature-verified
323
+ webhooks, streaming endpoints — that still runs the whole server pipeline:
324
+ correlation, hooks (rate limiting, idempotency, CORS, logging, error
325
+ reporting), context creation, instrumentation, and framework error mapping.
326
+ Request parsing and validation are skipped and the request body is left
327
+ unconsumed, so the handler owns body reading.
328
+
329
+ `init.name`, `init.method`, and `init.path` identify the route to hooks,
330
+ instrumentation, and devtools — routing itself belongs to the route file
331
+ that mounts the handler. `init.metadata` feeds metadata-driven hooks exactly
332
+ like contract metadata.
333
+
334
+ **Returns:** Builder with `handle(fn)` returning
335
+ `(req: Request) => Promise<Response>`.
336
+
337
+ ```typescript
338
+ // app/api/liveblocks-auth/route.ts
339
+ import { getServer } from "@/server";
340
+
341
+ let roomAuth: ((req: Request) => Promise<Response>) | undefined;
342
+
343
+ export async function POST(req: Request) {
344
+ roomAuth ??= (await getServer())
345
+ .rawRoute({
346
+ name: "collab.roomAuth",
347
+ method: "POST",
348
+ path: "/api/liveblocks-auth",
349
+ metadata: { rateLimit: { max: 300, windowSec: 60, scope: "user" } },
350
+ })
351
+ .handle(async ({ req, ctx }) => {
352
+ const body = await req.text();
353
+ return { status: 200, body: { token: "..." } };
354
+ });
355
+
356
+ return roomAuth(req);
357
+ }
358
+ ```
359
+
360
+ The webhook, payment webhook, schedule, and outbox drain route factories run
361
+ through this pipeline automatically when the `server` they receive exposes
362
+ `rawRoute(...)`; their `pipeline` option supplies the route identity and
363
+ metadata.
364
+
319
365
  #### `server.createContextFromNext()`
320
366
 
321
367
  Creates a context object from Next.js Server Components by automatically extracting headers and cookies. This allows you to call use cases directly from React Server Components without going through API routes.
@@ -556,13 +602,19 @@ The `action` segment must be `prepare`, `upload`, or `complete`.
556
602
 
557
603
  Use `createPaymentWebhookRoute(...)` for billing flows backed by
558
604
  `ctx.ports.payments`. Use `createWebhookRoute(...)` for generic inbound
559
- webhooks backed by a `defineWebhook(...)` catalog and a provider verifier. Both
560
- helpers read the raw request body first, then build app context from the real
561
- request with `server.createRequestContext(...)`, so signature verification
562
- never races the context factory over one body stream and trace headers
563
- propagate into context creation. The `server` option accepts any object
564
- exposing `createRequestContext` — a `NextServer`, a core `ServerInstance`, or
565
- a test fake so `server: getServer` keeps working unchanged.
605
+ webhooks backed by a `defineWebhook(...)` catalog and a provider verifier.
606
+ The `server` option accepts any object exposing `createRequestContext` a
607
+ `NextServer`, a core `ServerInstance`, or a test fake — so
608
+ `server: getServer` keeps working unchanged.
609
+
610
+ When the server also exposes `rawRoute(...)` — real Beignet servers do the
611
+ route runs inside the full hooks pipeline: rate limiting, CORS, logging,
612
+ error reporting, and instrumentation apply, the request appears in devtools,
613
+ and the `pipeline` option supplies the route identity and metadata for
614
+ metadata-driven hooks. The raw body stays unconsumed until the route reads
615
+ it, so signature verification still sees the exact bytes. Minimal test fakes
616
+ without `rawRoute` keep the direct flow: raw body first, then app context
617
+ through `server.createRequestContext(...)`.
566
618
 
567
619
  ```typescript
568
620
  // app/api/webhooks/github/route.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beignet/next",
3
- "version": "0.0.27",
3
+ "version": "0.0.28",
4
4
  "type": "module",
5
5
  "description": "Next.js server-side handlers for Beignet",
6
6
  "main": "./dist/index.js",
@@ -59,7 +59,7 @@
59
59
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0"
60
60
  },
61
61
  "dependencies": {
62
- "@beignet/web": "^0.0.27",
62
+ "@beignet/web": "^0.0.28",
63
63
  "@standard-schema/spec": "^1.0.0"
64
64
  },
65
65
  "devDependencies": {
package/src/index.ts CHANGED
@@ -24,7 +24,9 @@ import type {
24
24
  CreateServerOptions,
25
25
  Handler,
26
26
  HealthConfig,
27
+ HttpContractConfig,
27
28
  HttpRequestLike,
29
+ RawRouteInit,
28
30
  ResolveContract,
29
31
  RouteDef,
30
32
  ServerInstance,
@@ -49,6 +51,7 @@ export type {
49
51
  AnyUseCaseLike,
50
52
  CreateServerOptions,
51
53
  HandlerRouteDef,
54
+ RawRouteInit,
52
55
  RouteDef,
53
56
  RouteGroup,
54
57
  RouteHook,
@@ -99,6 +102,80 @@ async function resolveServerSource<TServer>(
99
102
  return isServerLoader(source) ? source() : source;
100
103
  }
101
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
+
102
179
  /**
103
180
  * Beignet client config with Next.js base URL defaults.
104
181
  */
@@ -293,6 +370,11 @@ type OutboxDrainContext = {
293
370
  * under `bun test`: construct the route with a fake server exposing
294
371
  * `createRequestContext` and invoke the exported handlers with a plain
295
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.
296
378
  */
297
379
  export type NextRouteServer<Ctx> = {
298
380
  createRequestContext: (req: HttpRequestLike) => Promise<Ctx>;
@@ -332,6 +414,17 @@ export type CreateOutboxDrainRouteOptions<Ctx extends OutboxDrainContext> = {
332
414
  * Retry delay override passed through to `drainOutbox(...)`.
333
415
  */
334
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
+ };
335
428
  /**
336
429
  * Additional response headers.
337
430
  */
@@ -470,6 +563,17 @@ export type CreateWebhookRouteOptions<
470
563
  handle?: (
471
564
  args: WebhookRouteHandlerArgs<Ctx, Webhook, AllowUnknownEvents>,
472
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
+ };
473
577
  /**
474
578
  * Additional response headers.
475
579
  */
@@ -510,6 +614,17 @@ export type CreateScheduleRouteOptions<Ctx extends ScheduleRouteContext> = {
510
614
  * @default "next-cron-route"
511
615
  */
512
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
+ };
513
628
  /**
514
629
  * Additional response headers.
515
630
  */
@@ -545,6 +660,17 @@ export type CreatePaymentWebhookRouteOptions<
545
660
  ) =>
546
661
  | Promise<PaymentWebhookRouteHandlerResult>
547
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
+ };
548
674
  /**
549
675
  * Additional response headers.
550
676
  */
@@ -579,6 +705,24 @@ export interface NextServer<
579
705
  fn: Handler<Ctx, ResolveContract<CLike>>,
580
706
  ) => (req: Request) => Promise<Response>;
581
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
+ };
582
726
  /**
583
727
  * Create app context from `next/headers` for Server Components.
584
728
  */
@@ -1297,8 +1441,15 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
1297
1441
  GET: (req: Request) => Promise<Response>;
1298
1442
  POST: (req: Request) => Promise<Response>;
1299
1443
  } {
1300
- const drainPendingOutbox = async (req: Request): Promise<Response> => {
1301
- const headers = resolveHeaders(options.headers, req);
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);
1302
1453
  const secret = options.secret;
1303
1454
 
1304
1455
  if (!secret) {
@@ -1312,15 +1463,11 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
1312
1463
  );
1313
1464
  }
1314
1465
 
1315
- if (!(await isAuthorizedCronRequest(req, secret))) {
1466
+ if (!(await isAuthorizedCronRequest(nativeReq, secret))) {
1316
1467
  return cronRouteJson({ ok: false, error: "Unauthorized" }, 401, headers);
1317
1468
  }
1318
1469
 
1319
- let ctx: Ctx | undefined;
1320
-
1321
1470
  try {
1322
- const server = await resolveServerSource(options.server);
1323
- ctx = await server.createRequestContext(toRequestLike(req));
1324
1471
  const drainCtx = ctx;
1325
1472
  const result = await drainOutbox({
1326
1473
  outbox: drainCtx.ports.outbox,
@@ -1375,7 +1522,7 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
1375
1522
  headers,
1376
1523
  );
1377
1524
  } catch (error) {
1378
- ctx?.ports.logger?.error("Outbox drain failed", { error });
1525
+ ctx.ports.logger?.error("Outbox drain failed", { error });
1379
1526
 
1380
1527
  return cronRouteJson(
1381
1528
  {
@@ -1388,6 +1535,69 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
1388
1535
  }
1389
1536
  };
1390
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
+
1391
1601
  return {
1392
1602
  GET: drainPendingOutbox,
1393
1603
  POST: drainPendingOutbox,
@@ -1412,13 +1622,154 @@ export function createWebhookRoute<
1412
1622
  ): {
1413
1623
  POST: (req: Request) => Promise<Response>;
1414
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
+
1415
1761
  return {
1416
1762
  POST: async (req) => {
1763
+ const server = await resolveServerSource(options.server);
1764
+
1765
+ const pipelined = runPipeline(server);
1766
+ if (pipelined) return pipelined(req);
1767
+
1768
+ // Minimal servers without rawRoute(...) — usually test fakes — build
1769
+ // context directly and run the same handler outside the hooks pipeline.
1417
1770
  const headers = resolveHeaders(options.headers, req);
1418
- const requestHeaders = headersToRecord(req.headers);
1419
1771
 
1420
1772
  let rawBody: string;
1421
-
1422
1773
  try {
1423
1774
  rawBody = await req.text();
1424
1775
  } catch {
@@ -1432,11 +1783,11 @@ export function createWebhookRoute<
1432
1783
  );
1433
1784
  }
1434
1785
 
1435
- let ctx: Ctx;
1786
+ const contextRequest = toContextRequest(req, rawBody);
1436
1787
 
1788
+ let ctx: Ctx;
1437
1789
  try {
1438
- const server = await resolveServerSource(options.server);
1439
- ctx = await server.createRequestContext(toContextRequest(req, rawBody));
1790
+ ctx = await server.createRequestContext(contextRequest);
1440
1791
  } catch {
1441
1792
  return webhookRouteJson(
1442
1793
  {
@@ -1448,108 +1799,7 @@ export function createWebhookRoute<
1448
1799
  );
1449
1800
  }
1450
1801
 
1451
- let event: WebhookRouteHandlerArgs<
1452
- Ctx,
1453
- Webhook,
1454
- AllowUnknownEvents
1455
- >["event"];
1456
-
1457
- try {
1458
- const input: VerifyWebhookInput = {
1459
- rawBody,
1460
- headers: requestHeaders,
1461
- receivedAt: new Date(),
1462
- };
1463
- const routeVerifier = options.verify;
1464
- const verifier = routeVerifier
1465
- ? {
1466
- async verify() {
1467
- return routeVerifier({
1468
- req,
1469
- ctx,
1470
- rawBody,
1471
- headers: requestHeaders,
1472
- input,
1473
- });
1474
- },
1475
- }
1476
- : undefined;
1477
-
1478
- event = (await verifyWebhook(options.webhook, input, {
1479
- verifier,
1480
- allowUnknownEvents: options.allowUnknownEvents,
1481
- })) as WebhookRouteHandlerArgs<
1482
- Ctx,
1483
- Webhook,
1484
- AllowUnknownEvents
1485
- >["event"];
1486
- } catch (error) {
1487
- ctx.ports.logger?.error("Webhook verification failed", {
1488
- error,
1489
- webhookName: options.webhook.name,
1490
- provider: options.webhook.provider,
1491
- requestId: ctx.requestId,
1492
- traceId: ctx.traceId,
1493
- });
1494
-
1495
- return webhookRouteJson(
1496
- {
1497
- ok: false,
1498
- error: "Webhook verification failed.",
1499
- },
1500
- 400,
1501
- headers,
1502
- );
1503
- }
1504
-
1505
- try {
1506
- const result = await options.handle?.({
1507
- req,
1508
- ctx,
1509
- rawBody,
1510
- event,
1511
- });
1512
-
1513
- if (result instanceof Response) return result;
1514
-
1515
- if (result) {
1516
- return toWebResponse({
1517
- status: result.status ?? 200,
1518
- headers: toHttpResponseHeaders(result.headers ?? headers),
1519
- body: result.body,
1520
- });
1521
- }
1522
-
1523
- return toWebResponse({
1524
- status: 200,
1525
- headers: toHttpResponseHeaders(headers),
1526
- body: {
1527
- ok: true,
1528
- webhookName: options.webhook.name,
1529
- eventId: event.id,
1530
- eventType: event.type,
1531
- },
1532
- });
1533
- } catch (error) {
1534
- ctx.ports.logger?.error("Webhook handler failed", {
1535
- error,
1536
- webhookName: options.webhook.name,
1537
- provider: options.webhook.provider,
1538
- requestId: ctx.requestId,
1539
- traceId: ctx.traceId,
1540
- eventId: event.id,
1541
- eventType: event.type,
1542
- });
1543
-
1544
- return webhookRouteJson(
1545
- {
1546
- ok: false,
1547
- error: "Webhook handler failed.",
1548
- },
1549
- 500,
1550
- headers,
1551
- );
1552
- }
1802
+ return handleWebhookRequest({ req: toContextRequest(req, rawBody), ctx });
1553
1803
  },
1554
1804
  };
1555
1805
  }
@@ -1573,12 +1823,138 @@ export function createPaymentWebhookRoute<
1573
1823
  } {
1574
1824
  const signatureHeader = options.signatureHeader ?? "stripe-signature";
1575
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
+
1576
1945
  return {
1577
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.
1578
1955
  const headers = resolveHeaders(options.headers, req);
1579
- const signature = req.headers.get(signatureHeader);
1580
1956
 
1581
- if (!signature) {
1957
+ if (!req.headers.get(signatureHeader)) {
1582
1958
  return paymentWebhookJson(
1583
1959
  {
1584
1960
  ok: false,
@@ -1590,7 +1966,6 @@ export function createPaymentWebhookRoute<
1590
1966
  }
1591
1967
 
1592
1968
  let rawBody: string;
1593
-
1594
1969
  try {
1595
1970
  rawBody = await req.text();
1596
1971
  } catch {
@@ -1605,9 +1980,7 @@ export function createPaymentWebhookRoute<
1605
1980
  }
1606
1981
 
1607
1982
  let ctx: Ctx;
1608
-
1609
1983
  try {
1610
- const server = await resolveServerSource(options.server);
1611
1984
  ctx = await server.createRequestContext(toContextRequest(req, rawBody));
1612
1985
  } catch {
1613
1986
  return paymentWebhookJson(
@@ -1620,76 +1993,7 @@ export function createPaymentWebhookRoute<
1620
1993
  );
1621
1994
  }
1622
1995
 
1623
- let event: PaymentWebhookEvent;
1624
-
1625
- try {
1626
- event = await ctx.ports.payments.verifyWebhook({
1627
- rawBody,
1628
- signature,
1629
- });
1630
- } catch (error) {
1631
- ctx.ports.logger?.error("Payment webhook verification failed", {
1632
- error,
1633
- requestId: ctx.requestId,
1634
- traceId: ctx.traceId,
1635
- });
1636
-
1637
- return paymentWebhookJson(
1638
- {
1639
- ok: false,
1640
- error: "Payment webhook verification failed.",
1641
- },
1642
- 400,
1643
- headers,
1644
- );
1645
- }
1646
-
1647
- try {
1648
- const result = await options.handle?.({
1649
- req,
1650
- ctx,
1651
- rawBody,
1652
- signature,
1653
- event,
1654
- });
1655
-
1656
- if (result instanceof Response) return result;
1657
-
1658
- if (result) {
1659
- return toWebResponse({
1660
- status: result.status ?? 200,
1661
- headers: toHttpResponseHeaders(result.headers ?? headers),
1662
- body: result.body,
1663
- });
1664
- }
1665
-
1666
- return toWebResponse({
1667
- status: 200,
1668
- headers: toHttpResponseHeaders(headers),
1669
- body: {
1670
- ok: true,
1671
- eventId: event.id,
1672
- eventType: event.type,
1673
- },
1674
- });
1675
- } catch (error) {
1676
- ctx.ports.logger?.error("Payment webhook handler failed", {
1677
- error,
1678
- requestId: ctx.requestId,
1679
- traceId: ctx.traceId,
1680
- eventId: event.id,
1681
- eventType: event.type,
1682
- });
1683
-
1684
- return paymentWebhookJson(
1685
- {
1686
- ok: false,
1687
- error: "Payment webhook handler failed.",
1688
- },
1689
- 500,
1690
- headers,
1691
- );
1692
- }
1996
+ return handlePaymentWebhook({ req: toContextRequest(req, rawBody), ctx });
1693
1997
  },
1694
1998
  };
1695
1999
  }
@@ -1726,8 +2030,15 @@ export function createScheduleRoute<Ctx extends ScheduleRouteContext>(
1726
2030
  );
1727
2031
  }
1728
2032
 
1729
- const runScheduleFromRequest = async (req: Request): Promise<Response> => {
1730
- const headers = resolveHeaders(options.headers, req);
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);
1731
2042
  const secret = options.secret;
1732
2043
 
1733
2044
  if (!secret) {
@@ -1742,13 +2053,11 @@ export function createScheduleRoute<Ctx extends ScheduleRouteContext>(
1742
2053
  );
1743
2054
  }
1744
2055
 
1745
- if (!(await isAuthorizedCronRequest(req, secret))) {
2056
+ if (!(await isAuthorizedCronRequest(nativeReq, secret))) {
1746
2057
  return cronRouteJson({ ok: false, error: "Unauthorized" }, 401, headers);
1747
2058
  }
1748
2059
 
1749
2060
  try {
1750
- const server = await resolveServerSource(options.server);
1751
- const ctx = await server.createRequestContext(toRequestLike(req));
1752
2061
  const runner = createInlineScheduleRunner<Ctx>({
1753
2062
  ctx,
1754
2063
  instrumentation: resolveProviderInstrumentationPort(ctx.ports),
@@ -1789,6 +2098,69 @@ export function createScheduleRoute<Ctx extends ScheduleRouteContext>(
1789
2098
  }
1790
2099
  };
1791
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
+
1792
2164
  return {
1793
2165
  GET: runScheduleFromRequest,
1794
2166
  POST: runScheduleFromRequest,
@@ -1820,6 +2192,9 @@ export async function createNextServer<
1820
2192
  route: (contract) => ({
1821
2193
  handle: (fn) => createFetchHandler(runtime.route(contract).handle(fn)),
1822
2194
  }),
2195
+ rawRoute: (init) => ({
2196
+ handle: (fn) => createFetchHandler(runtime.rawRoute(init).handle(fn)),
2197
+ }),
1823
2198
  createContextFromNext: async () => {
1824
2199
  // "next" ships no package exports map, so Node-style ESM resolution
1825
2200
  // (NodeNext) needs the explicit .js subpath. Bundlers resolve it the same.