@beignet/next 0.0.50 → 0.0.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ import type {
43
43
  StandardSchemaV1,
44
44
  } from "@beignet/core/server";
45
45
  import { createHealthHandler, createServer } from "@beignet/core/server";
46
+ import type { TracingPort } from "@beignet/core/tracing";
46
47
  import type { UploadRouter } from "@beignet/core/uploads";
47
48
  import {
48
49
  type InferWebhookEvent,
@@ -333,6 +334,7 @@ type OutboxDrainPorts = {
333
334
  logger?: OutboxDrainLogger;
334
335
  devtools?: OutboxDrainInstrumentation;
335
336
  instrumentation?: OutboxDrainInstrumentation;
337
+ tracing?: TracingPort;
336
338
  errorReporter?: ErrorReporterPort;
337
339
  };
338
340
 
@@ -344,7 +346,10 @@ type OutboxDrainContext = {
344
346
 
345
347
  type OutboxDrainRuntimeOptions = {
346
348
  batchSize?: number;
349
+ concurrency?: number;
347
350
  leaseMs?: number;
351
+ heartbeatMs?: number;
352
+ maxActiveMs?: number;
348
353
  retryDelayMs?: DrainOutboxOptions["retryDelayMs"];
349
354
  };
350
355
 
@@ -382,10 +387,16 @@ export type CreateNextOutboxDrainTriggerOptions<
382
387
  createContext: () => MaybePromise<Ctx>;
383
388
  /** Static registry or lazy loader used to avoid server/provider cycles. */
384
389
  registry: OutboxRegistry | (() => MaybePromise<OutboxRegistry>);
385
- /** Maximum messages to claim in the deferred drain pass. */
390
+ /** Maximum eligible messages to handle in the deferred drain pass. */
386
391
  batchSize?: number;
392
+ /** Maximum messages delivered concurrently. Values above one are unordered. */
393
+ concurrency?: number;
387
394
  /** Claim lease duration in milliseconds. */
388
395
  leaseMs?: number;
396
+ /** Serialized claim-renewal interval. Must remain shorter than `leaseMs`. */
397
+ heartbeatMs?: number;
398
+ /** Maximum time Beignet renews a claim for one delivery. */
399
+ maxActiveMs?: number;
389
400
  /** Retry delay override passed through to `drainOutbox(...)`. */
390
401
  retryDelayMs?: DrainOutboxOptions["retryDelayMs"];
391
402
  /**
@@ -436,13 +447,19 @@ export type CreateOutboxDrainRouteOptions<Ctx extends OutboxDrainContext> = {
436
447
  */
437
448
  secret?: string;
438
449
  /**
439
- * Maximum messages to claim in one drain pass.
450
+ * Maximum eligible messages to handle in one drain pass.
440
451
  */
441
452
  batchSize?: number;
453
+ /** Maximum messages delivered concurrently. Values above one are unordered. */
454
+ concurrency?: number;
442
455
  /**
443
456
  * Claim lease duration in milliseconds.
444
457
  */
445
458
  leaseMs?: number;
459
+ /** Serialized claim-renewal interval. Must remain shorter than `leaseMs`. */
460
+ heartbeatMs?: number;
461
+ /** Maximum time Beignet renews a claim for one delivery. */
462
+ maxActiveMs?: number;
446
463
  /**
447
464
  * Retry delay override passed through to `drainOutbox(...)`.
448
465
  */
@@ -589,6 +606,15 @@ export type CreateWebhookRouteOptions<
589
606
  * @default false
590
607
  */
591
608
  allowUnknownEvents?: AllowUnknownEvents;
609
+ /**
610
+ * Maximum raw webhook body size in bytes.
611
+ *
612
+ * Bodies above this limit are rejected before server resolution, hooks,
613
+ * context creation, verification, or fulfillment.
614
+ *
615
+ * @default 1048576
616
+ */
617
+ maxBodyBytes?: number;
592
618
  /**
593
619
  * App-owned fulfillment handler. The webhook is verified before this runs.
594
620
  *
@@ -684,6 +710,16 @@ export type CreatePaymentWebhookRouteOptions<
684
710
  * @default "stripe-signature"
685
711
  */
686
712
  signatureHeader?: string;
713
+ /**
714
+ * Maximum raw webhook body size in bytes.
715
+ *
716
+ * Signed bodies above this limit are rejected before provider verification
717
+ * or fulfillment, and before server resolution, hooks, or context creation.
718
+ * A missing signature takes precedence and cancels the unread body.
719
+ *
720
+ * @default 1048576
721
+ */
722
+ maxBodyBytes?: number;
687
723
  /**
688
724
  * App-owned fulfillment handler. The webhook is verified before this runs.
689
725
  *
@@ -1100,40 +1136,59 @@ async function storageResponse(
1100
1136
  if (!key) return storageNotFoundResponse();
1101
1137
 
1102
1138
  const object = await storage.get(key);
1103
- if (object?.visibility !== "public") {
1139
+ if (!object) {
1104
1140
  return storageNotFoundResponse();
1105
1141
  }
1106
-
1107
- const headers = new Headers(resolveHeaders(options.headers, req));
1108
- headers.set("content-length", String(object.size));
1109
- headers.set("last-modified", object.lastModified.toUTCString());
1110
- if (!headers.has("x-content-type-options")) {
1111
- headers.set("x-content-type-options", "nosniff");
1142
+ if (object.visibility !== "public") {
1143
+ await object.cancel().catch(() => undefined);
1144
+ return storageNotFoundResponse();
1112
1145
  }
1113
1146
 
1114
- if (object.contentType && !headers.has("content-type")) {
1115
- headers.set("content-type", object.contentType);
1116
- }
1147
+ try {
1148
+ const headers = new Headers(resolveHeaders(options.headers, req));
1149
+ headers.set("content-length", String(object.size));
1150
+ headers.set("last-modified", object.lastModified.toUTCString());
1151
+ if (!headers.has("x-content-type-options")) {
1152
+ headers.set("x-content-type-options", "nosniff");
1153
+ }
1117
1154
 
1118
- if (!headers.has("content-disposition")) {
1119
- const contentDisposition = resolveStorageContentDisposition(
1120
- options,
1121
- req,
1122
- object,
1123
- );
1124
- if (contentDisposition) {
1125
- headers.set("content-disposition", contentDisposition);
1155
+ if (object.contentType && !headers.has("content-type")) {
1156
+ headers.set("content-type", object.contentType);
1126
1157
  }
1127
- }
1128
1158
 
1129
- if (object.cacheControl && !headers.has("cache-control")) {
1130
- headers.set("cache-control", object.cacheControl);
1131
- }
1159
+ if (!headers.has("content-disposition")) {
1160
+ const contentDisposition = resolveStorageContentDisposition(
1161
+ options,
1162
+ req,
1163
+ object,
1164
+ );
1165
+ if (contentDisposition) {
1166
+ headers.set("content-disposition", contentDisposition);
1167
+ }
1168
+ }
1132
1169
 
1133
- return new Response(includeBody ? object.stream() : null, {
1134
- status: 200,
1135
- headers,
1136
- });
1170
+ if (object.cacheControl && !headers.has("cache-control")) {
1171
+ headers.set("cache-control", object.cacheControl);
1172
+ }
1173
+
1174
+ if (!includeBody) {
1175
+ await object.cancel();
1176
+ return new Response(null, { status: 200, headers });
1177
+ }
1178
+
1179
+ const body = object.stream();
1180
+ try {
1181
+ return new Response(body, { status: 200, headers });
1182
+ } catch (error) {
1183
+ await body.cancel(error).catch(() => undefined);
1184
+ throw error;
1185
+ }
1186
+ } catch (error) {
1187
+ if (!object.bodyUsed) {
1188
+ await object.cancel(error).catch(() => undefined);
1189
+ }
1190
+ throw error;
1191
+ }
1137
1192
  }
1138
1193
 
1139
1194
  function currentRequestServer(req: Request): OpenAPIServer {
@@ -1382,6 +1437,7 @@ type OperationalErrorCode =
1382
1437
  | "CRON_SECRET_NOT_CONFIGURED"
1383
1438
  | "OUTBOX_DRAIN_FAILED"
1384
1439
  | "PAYMENT_WEBHOOK_BODY_READ_FAILED"
1440
+ | "PAYMENT_WEBHOOK_BODY_TOO_LARGE"
1385
1441
  | "PAYMENT_WEBHOOK_CONTEXT_FAILED"
1386
1442
  | "PAYMENT_WEBHOOK_HANDLER_FAILED"
1387
1443
  | "PAYMENT_WEBHOOK_SIGNATURE_MISSING"
@@ -1389,6 +1445,7 @@ type OperationalErrorCode =
1389
1445
  | "SCHEDULE_FAILED"
1390
1446
  | "UNAUTHORIZED"
1391
1447
  | "WEBHOOK_BODY_READ_FAILED"
1448
+ | "WEBHOOK_BODY_TOO_LARGE"
1392
1449
  | "WEBHOOK_CONTEXT_FAILED"
1393
1450
  | "WEBHOOK_HANDLER_FAILED"
1394
1451
  | "WEBHOOK_VERIFICATION_FAILED";
@@ -1431,14 +1488,94 @@ function toHttpResponseHeaders(
1431
1488
  * creation then receives a rehydrated request carrying the same method, URL,
1432
1489
  * headers, and raw body.
1433
1490
  */
1434
- function toContextRequest(req: Request, rawBody: string): HttpRequestLike {
1435
- return toRequestLike(
1436
- new Request(req.url, {
1437
- method: req.method,
1438
- headers: req.headers,
1439
- body: rawBody,
1440
- }),
1441
- );
1491
+ function rehydrateWebhookRequest(req: Request, rawBody: string): Request {
1492
+ return new Request(req.url, {
1493
+ method: req.method,
1494
+ headers: req.headers,
1495
+ body: rawBody,
1496
+ signal: req.signal,
1497
+ });
1498
+ }
1499
+
1500
+ const DEFAULT_WEBHOOK_BODY_MAX_BYTES = 1024 * 1024;
1501
+
1502
+ class WebhookBodyTooLargeError extends Error {
1503
+ constructor(readonly maxBytes: number) {
1504
+ super("Webhook body exceeds the configured size limit.");
1505
+ this.name = "WebhookBodyTooLargeError";
1506
+ }
1507
+ }
1508
+
1509
+ function webhookBodyLimit(maxBytes: number | undefined): number {
1510
+ const resolved = maxBytes ?? DEFAULT_WEBHOOK_BODY_MAX_BYTES;
1511
+ if (!Number.isFinite(resolved) || resolved <= 0) {
1512
+ throw new Error("Webhook maxBodyBytes must be a positive number.");
1513
+ }
1514
+ return resolved;
1515
+ }
1516
+
1517
+ function cancelWebhookBody(
1518
+ source: { cancel(reason?: unknown): Promise<void> },
1519
+ reason: unknown,
1520
+ ): void {
1521
+ try {
1522
+ void source.cancel(reason).catch(() => {});
1523
+ } catch {
1524
+ // Cancellation is best-effort and must not replace the selected response.
1525
+ }
1526
+ }
1527
+
1528
+ function cancelWebhookRequestBody(req: HttpRequestLike, reason: unknown): void {
1529
+ const body = req.raw?.body ?? nativeRequestOf(req).body;
1530
+ if (body) cancelWebhookBody(body, reason);
1531
+ }
1532
+
1533
+ async function readWebhookBody(
1534
+ req: HttpRequestLike,
1535
+ maxBytes: number,
1536
+ ): Promise<string> {
1537
+ const body = req.raw?.body ?? nativeRequestOf(req).body;
1538
+ const contentLength = req.headers.get("content-length");
1539
+
1540
+ if (contentLength !== null) {
1541
+ const declaredBytes = Number(contentLength);
1542
+ if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
1543
+ const error = new WebhookBodyTooLargeError(maxBytes);
1544
+ if (body) cancelWebhookBody(body, error);
1545
+ throw error;
1546
+ }
1547
+ }
1548
+
1549
+ if (!body) {
1550
+ const text = await req.text();
1551
+ if (new TextEncoder().encode(text).byteLength > maxBytes) {
1552
+ throw new WebhookBodyTooLargeError(maxBytes);
1553
+ }
1554
+ return text;
1555
+ }
1556
+
1557
+ const reader = body.getReader();
1558
+ const decoder = new TextDecoder();
1559
+ let receivedBytes = 0;
1560
+ let text = "";
1561
+
1562
+ try {
1563
+ while (true) {
1564
+ const result = await reader.read();
1565
+ if (result.done) break;
1566
+
1567
+ receivedBytes += result.value.byteLength;
1568
+ if (receivedBytes > maxBytes) {
1569
+ const error = new WebhookBodyTooLargeError(maxBytes);
1570
+ cancelWebhookBody(reader, error);
1571
+ throw error;
1572
+ }
1573
+ text += decoder.decode(result.value, { stream: true });
1574
+ }
1575
+ return text + decoder.decode();
1576
+ } finally {
1577
+ reader.releaseLock();
1578
+ }
1442
1579
  }
1443
1580
 
1444
1581
  function headersToRecord(headers: Headers): Record<string, string> {
@@ -1532,9 +1669,15 @@ async function runOutboxDrainWithContext(
1532
1669
  eventBus: ctx.ports.eventBus,
1533
1670
  jobs: ctx.ports.jobs,
1534
1671
  batchSize: options.batchSize,
1672
+ concurrency: options.concurrency,
1535
1673
  leaseMs: options.leaseMs,
1674
+ heartbeatMs: options.heartbeatMs,
1675
+ maxActiveMs: options.maxActiveMs,
1536
1676
  retryDelayMs: options.retryDelayMs,
1537
- instrumentation: resolveProviderInstrumentationPort(ctx.ports),
1677
+ instrumentation: {
1678
+ instrumentation: resolveProviderInstrumentationPort(ctx.ports),
1679
+ tracing: ctx.ports.tracing,
1680
+ },
1538
1681
  instrumentationContext: {
1539
1682
  requestId: ctx.requestId,
1540
1683
  traceId: ctx.traceId,
@@ -1577,10 +1720,48 @@ async function runOutboxDrainWithContext(
1577
1720
  },
1578
1721
  });
1579
1722
  },
1580
- async onSettlementError(settlementError, message) {
1723
+ async onLeaseError(failure) {
1724
+ const outcome =
1725
+ failure.state === "lost"
1726
+ ? "leaseLost"
1727
+ : failure.state === "recovered"
1728
+ ? "leaseRecovered"
1729
+ : "leaseDegraded";
1581
1730
  await tryReportException({
1582
1731
  reporter: ctx.ports.errorReporter,
1583
- error: settlementError,
1732
+ error: failure.error,
1733
+ reportOptions: {
1734
+ level: failure.state === "lost" ? "error" : "warning",
1735
+ mechanism,
1736
+ handled: failure.state !== "lost",
1737
+ requestId: ctx.requestId,
1738
+ traceId: ctx.traceId,
1739
+ tags: {
1740
+ "beignet.kind": "outbox",
1741
+ "beignet.outbox.kind": failure.message.kind,
1742
+ "beignet.outbox.name": failure.message.name,
1743
+ "beignet.outbox.outcome": outcome,
1744
+ },
1745
+ contexts: {
1746
+ outbox: {
1747
+ messageId: failure.message.id,
1748
+ kind: failure.message.kind,
1749
+ name: failure.message.name,
1750
+ attempts: failure.message.attempts,
1751
+ maxAttempts: failure.message.maxAttempts,
1752
+ operation: failure.operation,
1753
+ state: failure.state,
1754
+ confirmedLost: failure.confirmedLost,
1755
+ outcome,
1756
+ },
1757
+ },
1758
+ },
1759
+ });
1760
+ },
1761
+ async onSettlementError(failure) {
1762
+ await tryReportException({
1763
+ reporter: ctx.ports.errorReporter,
1764
+ error: failure.error,
1584
1765
  reportOptions: {
1585
1766
  level: "error",
1586
1767
  mechanism,
@@ -1589,17 +1770,19 @@ async function runOutboxDrainWithContext(
1589
1770
  traceId: ctx.traceId,
1590
1771
  tags: {
1591
1772
  "beignet.kind": "outbox",
1592
- "beignet.outbox.kind": message.kind,
1593
- "beignet.outbox.name": message.name,
1773
+ "beignet.outbox.kind": failure.message.kind,
1774
+ "beignet.outbox.name": failure.message.name,
1594
1775
  "beignet.outbox.outcome": "settlementFailed",
1595
1776
  },
1596
1777
  contexts: {
1597
1778
  outbox: {
1598
- messageId: message.id,
1599
- kind: message.kind,
1600
- name: message.name,
1601
- attempts: message.attempts,
1602
- maxAttempts: message.maxAttempts,
1779
+ messageId: failure.message.id,
1780
+ kind: failure.message.kind,
1781
+ name: failure.message.name,
1782
+ attempts: failure.message.attempts,
1783
+ maxAttempts: failure.message.maxAttempts,
1784
+ operation: failure.operation,
1785
+ deliverySucceeded: failure.deliverySucceeded,
1603
1786
  outcome: "settlementFailed",
1604
1787
  },
1605
1788
  },
@@ -1614,7 +1797,7 @@ async function runOutboxDrainWithContext(
1614
1797
  watcher: "outbox",
1615
1798
  name: "outbox.drain",
1616
1799
  label: "Outbox drain",
1617
- summary: `${result.delivered} delivered, ${result.retried} retried, ${result.deadLettered} dead-lettered`,
1800
+ summary: `${result.delivered} delivered, ${result.retried} retried, ${result.deadLettered} dead-lettered, ${result.settlementFailed} settlement-uncertain, ${result.leaseLost} lease-lost`,
1618
1801
  requestId: ctx.requestId,
1619
1802
  traceId: ctx.traceId,
1620
1803
  details: result,
@@ -1628,6 +1811,16 @@ async function runOutboxDrainWithContext(
1628
1811
  return result;
1629
1812
  }
1630
1813
 
1814
+ function hasUncertainOutboxResult(result: DrainOutboxResult): boolean {
1815
+ return result.settlementFailed > 0 || result.leaseLost > 0;
1816
+ }
1817
+
1818
+ function uncertainOutboxError(result: DrainOutboxResult): Error {
1819
+ return new Error(
1820
+ `Outbox drain completed with ${result.settlementFailed} settlement failure(s) and ${result.leaseLost} lost lease(s).`,
1821
+ );
1822
+ }
1823
+
1631
1824
  async function resolveOutboxRegistry(
1632
1825
  source: OutboxRegistry | (() => MaybePromise<OutboxRegistry>),
1633
1826
  ): Promise<OutboxRegistry> {
@@ -1712,12 +1905,16 @@ export function createNextOutboxDrainTrigger<Ctx extends OutboxDrainContext>(
1712
1905
  try {
1713
1906
  ctx = await options.createContext();
1714
1907
  const registry = await resolveOutboxRegistry(options.registry);
1715
- await runOutboxDrainWithContext(
1908
+ const result = await runOutboxDrainWithContext(
1716
1909
  ctx,
1717
1910
  registry,
1718
1911
  options,
1719
1912
  "beignet.outbox.next.after",
1720
1913
  );
1914
+ if (hasUncertainOutboxResult(result)) {
1915
+ const error = uncertainOutboxError(result);
1916
+ await notifyDeferredWorkError(options.onError, error);
1917
+ }
1721
1918
  } catch (error) {
1722
1919
  if (ctx) {
1723
1920
  try {
@@ -1777,6 +1974,22 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
1777
1974
  "beignet.outbox.next",
1778
1975
  );
1779
1976
 
1977
+ if (hasUncertainOutboxResult(result)) {
1978
+ return routeJson(
1979
+ {
1980
+ ok: false,
1981
+ error: {
1982
+ code: "OUTBOX_DRAIN_UNCERTAIN",
1983
+ message:
1984
+ "Outbox drain completed with unconfirmed delivery state.",
1985
+ },
1986
+ result,
1987
+ },
1988
+ 500,
1989
+ headers,
1990
+ );
1991
+ }
1992
+
1780
1993
  return routeJson(
1781
1994
  {
1782
1995
  ok: true,
@@ -1862,8 +2075,9 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
1862
2075
  /**
1863
2076
  * Create a Next.js POST handler for generic verified inbound webhooks.
1864
2077
  *
1865
- * The route reads the raw request body as text, normalizes request headers,
1866
- * builds app context from the real request through
2078
+ * The route bounds and reads the raw request body before resolving the server,
2079
+ * then normalizes request headers and builds app context from a rehydrated
2080
+ * request through
1867
2081
  * `server.createRequestContext(...)`, verifies the event through the webhook
1868
2082
  * definition or a context-aware verifier, validates the matching event
1869
2083
  * payload schema, and delegates fulfillment to app code.
@@ -1877,28 +2091,43 @@ export function createWebhookRoute<
1877
2091
  ): {
1878
2092
  POST: (req: Request) => Promise<Response>;
1879
2093
  } {
2094
+ const maxBodyBytes = webhookBodyLimit(options.maxBodyBytes);
2095
+ const preflightBodies = new WeakMap<Request, string>();
2096
+
1880
2097
  const handleWebhookRequest = async ({
1881
2098
  req,
1882
2099
  ctx,
2100
+ rawBody: providedRawBody,
1883
2101
  }: {
1884
2102
  req: HttpRequestLike;
1885
2103
  ctx: Ctx;
2104
+ rawBody?: string;
1886
2105
  }): Promise<Response> => {
1887
2106
  const nativeReq = nativeRequestOf(req);
1888
2107
  const headers = resolveHeaders(options.headers, nativeReq);
1889
2108
  const requestHeaders = headersToRecord(new Headers(req.headers));
1890
2109
 
1891
- let rawBody: string;
2110
+ let rawBody = providedRawBody ?? preflightBodies.get(nativeReq);
2111
+ preflightBodies.delete(nativeReq);
1892
2112
 
1893
- try {
1894
- rawBody = await req.text();
1895
- } catch {
1896
- return operationalErrorJson(
1897
- "WEBHOOK_BODY_READ_FAILED",
1898
- "Webhook body read failed.",
1899
- 400,
1900
- headers,
1901
- );
2113
+ if (rawBody === undefined) {
2114
+ try {
2115
+ rawBody = await readWebhookBody(req, maxBodyBytes);
2116
+ } catch (error) {
2117
+ return error instanceof WebhookBodyTooLargeError
2118
+ ? operationalErrorJson(
2119
+ "WEBHOOK_BODY_TOO_LARGE",
2120
+ "Webhook body exceeds the configured size limit.",
2121
+ 413,
2122
+ headers,
2123
+ )
2124
+ : operationalErrorJson(
2125
+ "WEBHOOK_BODY_READ_FAILED",
2126
+ "Webhook body read failed.",
2127
+ 400,
2128
+ headers,
2129
+ );
2130
+ }
1902
2131
  }
1903
2132
 
1904
2133
  let event: WebhookRouteHandlerArgs<
@@ -2009,32 +2238,42 @@ export function createWebhookRoute<
2009
2238
 
2010
2239
  return {
2011
2240
  POST: async (req) => {
2012
- const server = await resolveServerSource(options.server);
2013
-
2014
- const pipelined = runPipeline(server);
2015
- if (pipelined) return pipelined(req);
2016
-
2017
- // Minimal servers without rawRoute(...) — usually test fakes — build
2018
- // context directly and run the same handler outside the hooks pipeline.
2019
2241
  const headers = resolveHeaders(options.headers, req);
2020
2242
 
2021
2243
  let rawBody: string;
2022
2244
  try {
2023
- rawBody = await req.text();
2024
- } catch {
2025
- return operationalErrorJson(
2026
- "WEBHOOK_BODY_READ_FAILED",
2027
- "Webhook body read failed.",
2028
- 400,
2029
- headers,
2030
- );
2245
+ rawBody = await readWebhookBody(toRequestLike(req), maxBodyBytes);
2246
+ } catch (error) {
2247
+ return error instanceof WebhookBodyTooLargeError
2248
+ ? operationalErrorJson(
2249
+ "WEBHOOK_BODY_TOO_LARGE",
2250
+ "Webhook body exceeds the configured size limit.",
2251
+ 413,
2252
+ headers,
2253
+ )
2254
+ : operationalErrorJson(
2255
+ "WEBHOOK_BODY_READ_FAILED",
2256
+ "Webhook body read failed.",
2257
+ 400,
2258
+ headers,
2259
+ );
2260
+ }
2261
+
2262
+ const contextRequest = rehydrateWebhookRequest(req, rawBody);
2263
+ const server = await resolveServerSource(options.server);
2264
+ const pipelined = runPipeline(server);
2265
+ if (pipelined) {
2266
+ preflightBodies.set(contextRequest, rawBody);
2267
+ return pipelined(contextRequest);
2031
2268
  }
2032
2269
 
2033
- const contextRequest = toContextRequest(req, rawBody);
2270
+ // Minimal servers without rawRoute(...) — usually test fakes — build
2271
+ // context directly and run the same handler outside the hooks pipeline.
2272
+ const requestLike = toRequestLike(contextRequest);
2034
2273
 
2035
2274
  let ctx: Ctx;
2036
2275
  try {
2037
- ctx = await server.createRequestContext(contextRequest);
2276
+ ctx = await server.createRequestContext(requestLike);
2038
2277
  } catch {
2039
2278
  return operationalErrorJson(
2040
2279
  "WEBHOOK_CONTEXT_FAILED",
@@ -2044,7 +2283,11 @@ export function createWebhookRoute<
2044
2283
  );
2045
2284
  }
2046
2285
 
2047
- return handleWebhookRequest({ req: toContextRequest(req, rawBody), ctx });
2286
+ return handleWebhookRequest({
2287
+ req: requestLike,
2288
+ ctx,
2289
+ rawBody,
2290
+ });
2048
2291
  },
2049
2292
  };
2050
2293
  }
@@ -2052,8 +2295,9 @@ export function createWebhookRoute<
2052
2295
  /**
2053
2296
  * Create a Next.js POST handler for provider-verified payment webhooks.
2054
2297
  *
2055
- * The route extracts the provider signature header, reads the raw request
2056
- * body as text, builds app context from the real request through
2298
+ * The route checks the provider signature header and bounds the raw request
2299
+ * body before resolving the server, then builds app context from a rehydrated
2300
+ * request through
2057
2301
  * `server.createRequestContext(...)`, verifies the event through
2058
2302
  * `ctx.ports.payments`, and then delegates fulfillment to app code. This is the canonical helper for Beignet
2059
2303
  * billing flows backed by `PaymentsPort`. Keep this route on the Node.js
@@ -2067,19 +2311,27 @@ export function createPaymentWebhookRoute<
2067
2311
  POST: (req: Request) => Promise<Response>;
2068
2312
  } {
2069
2313
  const signatureHeader = options.signatureHeader ?? "stripe-signature";
2314
+ const maxBodyBytes = webhookBodyLimit(options.maxBodyBytes);
2315
+ const preflightBodies = new WeakMap<Request, string>();
2070
2316
 
2071
2317
  const handlePaymentWebhook = async ({
2072
2318
  req,
2073
2319
  ctx,
2320
+ rawBody: providedRawBody,
2074
2321
  }: {
2075
2322
  req: HttpRequestLike;
2076
2323
  ctx: Ctx;
2324
+ rawBody?: string;
2077
2325
  }): Promise<Response> => {
2078
2326
  const nativeReq = nativeRequestOf(req);
2079
2327
  const headers = resolveHeaders(options.headers, nativeReq);
2080
2328
  const signature = new Headers(req.headers).get(signatureHeader);
2081
2329
 
2082
2330
  if (!signature) {
2331
+ cancelWebhookRequestBody(
2332
+ req,
2333
+ new Error(`Missing ${signatureHeader} header.`),
2334
+ );
2083
2335
  return operationalErrorJson(
2084
2336
  "PAYMENT_WEBHOOK_SIGNATURE_MISSING",
2085
2337
  `Missing ${signatureHeader} header.`,
@@ -2088,17 +2340,27 @@ export function createPaymentWebhookRoute<
2088
2340
  );
2089
2341
  }
2090
2342
 
2091
- let rawBody: string;
2343
+ let rawBody = providedRawBody ?? preflightBodies.get(nativeReq);
2344
+ preflightBodies.delete(nativeReq);
2092
2345
 
2093
- try {
2094
- rawBody = await req.text();
2095
- } catch {
2096
- return operationalErrorJson(
2097
- "PAYMENT_WEBHOOK_BODY_READ_FAILED",
2098
- "Payment webhook body read failed.",
2099
- 400,
2100
- headers,
2101
- );
2346
+ if (rawBody === undefined) {
2347
+ try {
2348
+ rawBody = await readWebhookBody(req, maxBodyBytes);
2349
+ } catch (error) {
2350
+ return error instanceof WebhookBodyTooLargeError
2351
+ ? operationalErrorJson(
2352
+ "PAYMENT_WEBHOOK_BODY_TOO_LARGE",
2353
+ "Payment webhook body exceeds the configured size limit.",
2354
+ 413,
2355
+ headers,
2356
+ )
2357
+ : operationalErrorJson(
2358
+ "PAYMENT_WEBHOOK_BODY_READ_FAILED",
2359
+ "Payment webhook body read failed.",
2360
+ 400,
2361
+ headers,
2362
+ );
2363
+ }
2102
2364
  }
2103
2365
 
2104
2366
  let event: PaymentWebhookEvent;
@@ -2181,17 +2443,15 @@ export function createPaymentWebhookRoute<
2181
2443
 
2182
2444
  return {
2183
2445
  POST: async (req) => {
2184
- const server = await resolveServerSource(options.server);
2185
-
2186
- const pipelined = runPipeline(server);
2187
- if (pipelined) return pipelined(req);
2188
-
2189
- // Minimal servers without rawRoute(...) — usually test fakes — build
2190
- // context directly and run the same handler outside the hooks pipeline.
2191
- // The signature gate runs first so no context is created without one.
2192
2446
  const headers = resolveHeaders(options.headers, req);
2193
2447
 
2194
2448
  if (!req.headers.get(signatureHeader)) {
2449
+ if (req.body) {
2450
+ cancelWebhookBody(
2451
+ req.body,
2452
+ new Error(`Missing ${signatureHeader} header.`),
2453
+ );
2454
+ }
2195
2455
  return operationalErrorJson(
2196
2456
  "PAYMENT_WEBHOOK_SIGNATURE_MISSING",
2197
2457
  `Missing ${signatureHeader} header.`,
@@ -2202,19 +2462,38 @@ export function createPaymentWebhookRoute<
2202
2462
 
2203
2463
  let rawBody: string;
2204
2464
  try {
2205
- rawBody = await req.text();
2206
- } catch {
2207
- return operationalErrorJson(
2208
- "PAYMENT_WEBHOOK_BODY_READ_FAILED",
2209
- "Payment webhook body read failed.",
2210
- 400,
2211
- headers,
2212
- );
2465
+ rawBody = await readWebhookBody(toRequestLike(req), maxBodyBytes);
2466
+ } catch (error) {
2467
+ return error instanceof WebhookBodyTooLargeError
2468
+ ? operationalErrorJson(
2469
+ "PAYMENT_WEBHOOK_BODY_TOO_LARGE",
2470
+ "Payment webhook body exceeds the configured size limit.",
2471
+ 413,
2472
+ headers,
2473
+ )
2474
+ : operationalErrorJson(
2475
+ "PAYMENT_WEBHOOK_BODY_READ_FAILED",
2476
+ "Payment webhook body read failed.",
2477
+ 400,
2478
+ headers,
2479
+ );
2213
2480
  }
2214
2481
 
2482
+ const contextRequest = rehydrateWebhookRequest(req, rawBody);
2483
+ const server = await resolveServerSource(options.server);
2484
+ const pipelined = runPipeline(server);
2485
+ if (pipelined) {
2486
+ preflightBodies.set(contextRequest, rawBody);
2487
+ return pipelined(contextRequest);
2488
+ }
2489
+
2490
+ // Minimal servers without rawRoute(...) — usually test fakes — build
2491
+ // context directly and run the same handler outside the hooks pipeline.
2492
+ const requestLike = toRequestLike(contextRequest);
2493
+
2215
2494
  let ctx: Ctx;
2216
2495
  try {
2217
- ctx = await server.createRequestContext(toContextRequest(req, rawBody));
2496
+ ctx = await server.createRequestContext(requestLike);
2218
2497
  } catch {
2219
2498
  return operationalErrorJson(
2220
2499
  "PAYMENT_WEBHOOK_CONTEXT_FAILED",
@@ -2224,7 +2503,11 @@ export function createPaymentWebhookRoute<
2224
2503
  );
2225
2504
  }
2226
2505
 
2227
- return handlePaymentWebhook({ req: toContextRequest(req, rawBody), ctx });
2506
+ return handlePaymentWebhook({
2507
+ req: requestLike,
2508
+ ctx,
2509
+ rawBody,
2510
+ });
2228
2511
  },
2229
2512
  };
2230
2513
  }