@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/CHANGELOG.md +26 -0
- package/README.md +57 -25
- package/dist/index.d.ts +41 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +271 -76
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/skills/routes-server/SKILL.md +13 -3
- package/src/index.ts +391 -108
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
|
|
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
|
|
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
|
|
1139
|
+
if (!object) {
|
|
1104
1140
|
return storageNotFoundResponse();
|
|
1105
1141
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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
|
-
|
|
1115
|
-
headers.
|
|
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
|
-
|
|
1119
|
-
|
|
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
|
-
|
|
1130
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
|
1435
|
-
return
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
1866
|
-
* builds app context from
|
|
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
|
|
2110
|
+
let rawBody = providedRawBody ?? preflightBodies.get(nativeReq);
|
|
2111
|
+
preflightBodies.delete(nativeReq);
|
|
1892
2112
|
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
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
|
|
2024
|
-
} catch {
|
|
2025
|
-
return
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
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
|
-
|
|
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(
|
|
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({
|
|
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
|
|
2056
|
-
* body
|
|
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
|
|
2343
|
+
let rawBody = providedRawBody ?? preflightBodies.get(nativeReq);
|
|
2344
|
+
preflightBodies.delete(nativeReq);
|
|
2092
2345
|
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
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
|
|
2206
|
-
} catch {
|
|
2207
|
-
return
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
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(
|
|
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({
|
|
2506
|
+
return handlePaymentWebhook({
|
|
2507
|
+
req: requestLike,
|
|
2508
|
+
ctx,
|
|
2509
|
+
rawBody,
|
|
2510
|
+
});
|
|
2228
2511
|
},
|
|
2229
2512
|
};
|
|
2230
2513
|
}
|