@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/dist/index.js CHANGED
@@ -241,31 +241,51 @@ async function storageResponse(storage, req, basePath, options, includeBody) {
241
241
  if (!key)
242
242
  return storageNotFoundResponse();
243
243
  const object = await storage.get(key);
244
- if (object?.visibility !== "public") {
244
+ if (!object) {
245
245
  return storageNotFoundResponse();
246
246
  }
247
- const headers = new Headers(resolveHeaders(options.headers, req));
248
- headers.set("content-length", String(object.size));
249
- headers.set("last-modified", object.lastModified.toUTCString());
250
- if (!headers.has("x-content-type-options")) {
251
- headers.set("x-content-type-options", "nosniff");
252
- }
253
- if (object.contentType && !headers.has("content-type")) {
254
- headers.set("content-type", object.contentType);
247
+ if (object.visibility !== "public") {
248
+ await object.cancel().catch(() => undefined);
249
+ return storageNotFoundResponse();
255
250
  }
256
- if (!headers.has("content-disposition")) {
257
- const contentDisposition = resolveStorageContentDisposition(options, req, object);
258
- if (contentDisposition) {
259
- headers.set("content-disposition", contentDisposition);
251
+ try {
252
+ const headers = new Headers(resolveHeaders(options.headers, req));
253
+ headers.set("content-length", String(object.size));
254
+ headers.set("last-modified", object.lastModified.toUTCString());
255
+ if (!headers.has("x-content-type-options")) {
256
+ headers.set("x-content-type-options", "nosniff");
257
+ }
258
+ if (object.contentType && !headers.has("content-type")) {
259
+ headers.set("content-type", object.contentType);
260
+ }
261
+ if (!headers.has("content-disposition")) {
262
+ const contentDisposition = resolveStorageContentDisposition(options, req, object);
263
+ if (contentDisposition) {
264
+ headers.set("content-disposition", contentDisposition);
265
+ }
266
+ }
267
+ if (object.cacheControl && !headers.has("cache-control")) {
268
+ headers.set("cache-control", object.cacheControl);
269
+ }
270
+ if (!includeBody) {
271
+ await object.cancel();
272
+ return new Response(null, { status: 200, headers });
273
+ }
274
+ const body = object.stream();
275
+ try {
276
+ return new Response(body, { status: 200, headers });
277
+ }
278
+ catch (error) {
279
+ await body.cancel(error).catch(() => undefined);
280
+ throw error;
260
281
  }
261
282
  }
262
- if (object.cacheControl && !headers.has("cache-control")) {
263
- headers.set("cache-control", object.cacheControl);
283
+ catch (error) {
284
+ if (!object.bodyUsed) {
285
+ await object.cancel(error).catch(() => undefined);
286
+ }
287
+ throw error;
264
288
  }
265
- return new Response(includeBody ? object.stream() : null, {
266
- status: 200,
267
- headers,
268
- });
269
289
  }
270
290
  function currentRequestServer(req) {
271
291
  const url = new URL(req.url);
@@ -466,12 +486,84 @@ function toHttpResponseHeaders(headers) {
466
486
  * creation then receives a rehydrated request carrying the same method, URL,
467
487
  * headers, and raw body.
468
488
  */
469
- function toContextRequest(req, rawBody) {
470
- return toRequestLike(new Request(req.url, {
489
+ function rehydrateWebhookRequest(req, rawBody) {
490
+ return new Request(req.url, {
471
491
  method: req.method,
472
492
  headers: req.headers,
473
493
  body: rawBody,
474
- }));
494
+ signal: req.signal,
495
+ });
496
+ }
497
+ const DEFAULT_WEBHOOK_BODY_MAX_BYTES = 1024 * 1024;
498
+ class WebhookBodyTooLargeError extends Error {
499
+ maxBytes;
500
+ constructor(maxBytes) {
501
+ super("Webhook body exceeds the configured size limit.");
502
+ this.maxBytes = maxBytes;
503
+ this.name = "WebhookBodyTooLargeError";
504
+ }
505
+ }
506
+ function webhookBodyLimit(maxBytes) {
507
+ const resolved = maxBytes ?? DEFAULT_WEBHOOK_BODY_MAX_BYTES;
508
+ if (!Number.isFinite(resolved) || resolved <= 0) {
509
+ throw new Error("Webhook maxBodyBytes must be a positive number.");
510
+ }
511
+ return resolved;
512
+ }
513
+ function cancelWebhookBody(source, reason) {
514
+ try {
515
+ void source.cancel(reason).catch(() => { });
516
+ }
517
+ catch {
518
+ // Cancellation is best-effort and must not replace the selected response.
519
+ }
520
+ }
521
+ function cancelWebhookRequestBody(req, reason) {
522
+ const body = req.raw?.body ?? nativeRequestOf(req).body;
523
+ if (body)
524
+ cancelWebhookBody(body, reason);
525
+ }
526
+ async function readWebhookBody(req, maxBytes) {
527
+ const body = req.raw?.body ?? nativeRequestOf(req).body;
528
+ const contentLength = req.headers.get("content-length");
529
+ if (contentLength !== null) {
530
+ const declaredBytes = Number(contentLength);
531
+ if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
532
+ const error = new WebhookBodyTooLargeError(maxBytes);
533
+ if (body)
534
+ cancelWebhookBody(body, error);
535
+ throw error;
536
+ }
537
+ }
538
+ if (!body) {
539
+ const text = await req.text();
540
+ if (new TextEncoder().encode(text).byteLength > maxBytes) {
541
+ throw new WebhookBodyTooLargeError(maxBytes);
542
+ }
543
+ return text;
544
+ }
545
+ const reader = body.getReader();
546
+ const decoder = new TextDecoder();
547
+ let receivedBytes = 0;
548
+ let text = "";
549
+ try {
550
+ while (true) {
551
+ const result = await reader.read();
552
+ if (result.done)
553
+ break;
554
+ receivedBytes += result.value.byteLength;
555
+ if (receivedBytes > maxBytes) {
556
+ const error = new WebhookBodyTooLargeError(maxBytes);
557
+ cancelWebhookBody(reader, error);
558
+ throw error;
559
+ }
560
+ text += decoder.decode(result.value, { stream: true });
561
+ }
562
+ return text + decoder.decode();
563
+ }
564
+ finally {
565
+ reader.releaseLock();
566
+ }
475
567
  }
476
568
  function headersToRecord(headers) {
477
569
  const result = {};
@@ -539,9 +631,15 @@ async function runOutboxDrainWithContext(ctx, registry, options, mechanism) {
539
631
  eventBus: ctx.ports.eventBus,
540
632
  jobs: ctx.ports.jobs,
541
633
  batchSize: options.batchSize,
634
+ concurrency: options.concurrency,
542
635
  leaseMs: options.leaseMs,
636
+ heartbeatMs: options.heartbeatMs,
637
+ maxActiveMs: options.maxActiveMs,
543
638
  retryDelayMs: options.retryDelayMs,
544
- instrumentation: resolveProviderInstrumentationPort(ctx.ports),
639
+ instrumentation: {
640
+ instrumentation: resolveProviderInstrumentationPort(ctx.ports),
641
+ tracing: ctx.ports.tracing,
642
+ },
545
643
  instrumentationContext: {
546
644
  requestId: ctx.requestId,
547
645
  traceId: ctx.traceId,
@@ -584,10 +682,47 @@ async function runOutboxDrainWithContext(ctx, registry, options, mechanism) {
584
682
  },
585
683
  });
586
684
  },
587
- async onSettlementError(settlementError, message) {
685
+ async onLeaseError(failure) {
686
+ const outcome = failure.state === "lost"
687
+ ? "leaseLost"
688
+ : failure.state === "recovered"
689
+ ? "leaseRecovered"
690
+ : "leaseDegraded";
691
+ await tryReportException({
692
+ reporter: ctx.ports.errorReporter,
693
+ error: failure.error,
694
+ reportOptions: {
695
+ level: failure.state === "lost" ? "error" : "warning",
696
+ mechanism,
697
+ handled: failure.state !== "lost",
698
+ requestId: ctx.requestId,
699
+ traceId: ctx.traceId,
700
+ tags: {
701
+ "beignet.kind": "outbox",
702
+ "beignet.outbox.kind": failure.message.kind,
703
+ "beignet.outbox.name": failure.message.name,
704
+ "beignet.outbox.outcome": outcome,
705
+ },
706
+ contexts: {
707
+ outbox: {
708
+ messageId: failure.message.id,
709
+ kind: failure.message.kind,
710
+ name: failure.message.name,
711
+ attempts: failure.message.attempts,
712
+ maxAttempts: failure.message.maxAttempts,
713
+ operation: failure.operation,
714
+ state: failure.state,
715
+ confirmedLost: failure.confirmedLost,
716
+ outcome,
717
+ },
718
+ },
719
+ },
720
+ });
721
+ },
722
+ async onSettlementError(failure) {
588
723
  await tryReportException({
589
724
  reporter: ctx.ports.errorReporter,
590
- error: settlementError,
725
+ error: failure.error,
591
726
  reportOptions: {
592
727
  level: "error",
593
728
  mechanism,
@@ -596,17 +731,19 @@ async function runOutboxDrainWithContext(ctx, registry, options, mechanism) {
596
731
  traceId: ctx.traceId,
597
732
  tags: {
598
733
  "beignet.kind": "outbox",
599
- "beignet.outbox.kind": message.kind,
600
- "beignet.outbox.name": message.name,
734
+ "beignet.outbox.kind": failure.message.kind,
735
+ "beignet.outbox.name": failure.message.name,
601
736
  "beignet.outbox.outcome": "settlementFailed",
602
737
  },
603
738
  contexts: {
604
739
  outbox: {
605
- messageId: message.id,
606
- kind: message.kind,
607
- name: message.name,
608
- attempts: message.attempts,
609
- maxAttempts: message.maxAttempts,
740
+ messageId: failure.message.id,
741
+ kind: failure.message.kind,
742
+ name: failure.message.name,
743
+ attempts: failure.message.attempts,
744
+ maxAttempts: failure.message.maxAttempts,
745
+ operation: failure.operation,
746
+ deliverySucceeded: failure.deliverySucceeded,
610
747
  outcome: "settlementFailed",
611
748
  },
612
749
  },
@@ -620,7 +757,7 @@ async function runOutboxDrainWithContext(ctx, registry, options, mechanism) {
620
757
  watcher: "outbox",
621
758
  name: "outbox.drain",
622
759
  label: "Outbox drain",
623
- summary: `${result.delivered} delivered, ${result.retried} retried, ${result.deadLettered} dead-lettered`,
760
+ summary: `${result.delivered} delivered, ${result.retried} retried, ${result.deadLettered} dead-lettered, ${result.settlementFailed} settlement-uncertain, ${result.leaseLost} lease-lost`,
624
761
  requestId: ctx.requestId,
625
762
  traceId: ctx.traceId,
626
763
  details: result,
@@ -633,6 +770,12 @@ async function runOutboxDrainWithContext(ctx, registry, options, mechanism) {
633
770
  }
634
771
  return result;
635
772
  }
773
+ function hasUncertainOutboxResult(result) {
774
+ return result.settlementFailed > 0 || result.leaseLost > 0;
775
+ }
776
+ function uncertainOutboxError(result) {
777
+ return new Error(`Outbox drain completed with ${result.settlementFailed} settlement failure(s) and ${result.leaseLost} lost lease(s).`);
778
+ }
636
779
  async function resolveOutboxRegistry(source) {
637
780
  return typeof source === "function" ? source() : source;
638
781
  }
@@ -704,7 +847,11 @@ export function createNextOutboxDrainTrigger(options) {
704
847
  try {
705
848
  ctx = await options.createContext();
706
849
  const registry = await resolveOutboxRegistry(options.registry);
707
- await runOutboxDrainWithContext(ctx, registry, options, "beignet.outbox.next.after");
850
+ const result = await runOutboxDrainWithContext(ctx, registry, options, "beignet.outbox.next.after");
851
+ if (hasUncertainOutboxResult(result)) {
852
+ const error = uncertainOutboxError(result);
853
+ await notifyDeferredWorkError(options.onError, error);
854
+ }
708
855
  }
709
856
  catch (error) {
710
857
  if (ctx) {
@@ -747,6 +894,16 @@ export function createOutboxDrainRoute(options) {
747
894
  const headers = resolveHeaders(options.headers, nativeReq);
748
895
  try {
749
896
  const result = await runOutboxDrainWithContext(ctx, options.registry, options, "beignet.outbox.next");
897
+ if (hasUncertainOutboxResult(result)) {
898
+ return routeJson({
899
+ ok: false,
900
+ error: {
901
+ code: "OUTBOX_DRAIN_UNCERTAIN",
902
+ message: "Outbox drain completed with unconfirmed delivery state.",
903
+ },
904
+ result,
905
+ }, 500, headers);
906
+ }
750
907
  return routeJson({
751
908
  ok: true,
752
909
  result,
@@ -798,23 +955,31 @@ export function createOutboxDrainRoute(options) {
798
955
  /**
799
956
  * Create a Next.js POST handler for generic verified inbound webhooks.
800
957
  *
801
- * The route reads the raw request body as text, normalizes request headers,
802
- * builds app context from the real request through
958
+ * The route bounds and reads the raw request body before resolving the server,
959
+ * then normalizes request headers and builds app context from a rehydrated
960
+ * request through
803
961
  * `server.createRequestContext(...)`, verifies the event through the webhook
804
962
  * definition or a context-aware verifier, validates the matching event
805
963
  * payload schema, and delegates fulfillment to app code.
806
964
  */
807
965
  export function createWebhookRoute(options) {
808
- const handleWebhookRequest = async ({ req, ctx, }) => {
966
+ const maxBodyBytes = webhookBodyLimit(options.maxBodyBytes);
967
+ const preflightBodies = new WeakMap();
968
+ const handleWebhookRequest = async ({ req, ctx, rawBody: providedRawBody, }) => {
809
969
  const nativeReq = nativeRequestOf(req);
810
970
  const headers = resolveHeaders(options.headers, nativeReq);
811
971
  const requestHeaders = headersToRecord(new Headers(req.headers));
812
- let rawBody;
813
- try {
814
- rawBody = await req.text();
815
- }
816
- catch {
817
- return operationalErrorJson("WEBHOOK_BODY_READ_FAILED", "Webhook body read failed.", 400, headers);
972
+ let rawBody = providedRawBody ?? preflightBodies.get(nativeReq);
973
+ preflightBodies.delete(nativeReq);
974
+ if (rawBody === undefined) {
975
+ try {
976
+ rawBody = await readWebhookBody(req, maxBodyBytes);
977
+ }
978
+ catch (error) {
979
+ return error instanceof WebhookBodyTooLargeError
980
+ ? operationalErrorJson("WEBHOOK_BODY_TOO_LARGE", "Webhook body exceeds the configured size limit.", 413, headers)
981
+ : operationalErrorJson("WEBHOOK_BODY_READ_FAILED", "Webhook body read failed.", 400, headers);
982
+ }
818
983
  }
819
984
  let event;
820
985
  try {
@@ -900,37 +1065,47 @@ export function createWebhookRoute(options) {
900
1065
  }, handleWebhookRequest);
901
1066
  return {
902
1067
  POST: async (req) => {
903
- const server = await resolveServerSource(options.server);
904
- const pipelined = runPipeline(server);
905
- if (pipelined)
906
- return pipelined(req);
907
- // Minimal servers without rawRoute(...) — usually test fakes — build
908
- // context directly and run the same handler outside the hooks pipeline.
909
1068
  const headers = resolveHeaders(options.headers, req);
910
1069
  let rawBody;
911
1070
  try {
912
- rawBody = await req.text();
1071
+ rawBody = await readWebhookBody(toRequestLike(req), maxBodyBytes);
913
1072
  }
914
- catch {
915
- return operationalErrorJson("WEBHOOK_BODY_READ_FAILED", "Webhook body read failed.", 400, headers);
1073
+ catch (error) {
1074
+ return error instanceof WebhookBodyTooLargeError
1075
+ ? operationalErrorJson("WEBHOOK_BODY_TOO_LARGE", "Webhook body exceeds the configured size limit.", 413, headers)
1076
+ : operationalErrorJson("WEBHOOK_BODY_READ_FAILED", "Webhook body read failed.", 400, headers);
1077
+ }
1078
+ const contextRequest = rehydrateWebhookRequest(req, rawBody);
1079
+ const server = await resolveServerSource(options.server);
1080
+ const pipelined = runPipeline(server);
1081
+ if (pipelined) {
1082
+ preflightBodies.set(contextRequest, rawBody);
1083
+ return pipelined(contextRequest);
916
1084
  }
917
- const contextRequest = toContextRequest(req, rawBody);
1085
+ // Minimal servers without rawRoute(...) — usually test fakes — build
1086
+ // context directly and run the same handler outside the hooks pipeline.
1087
+ const requestLike = toRequestLike(contextRequest);
918
1088
  let ctx;
919
1089
  try {
920
- ctx = await server.createRequestContext(contextRequest);
1090
+ ctx = await server.createRequestContext(requestLike);
921
1091
  }
922
1092
  catch {
923
1093
  return operationalErrorJson("WEBHOOK_CONTEXT_FAILED", "Webhook context failed.", 500, headers);
924
1094
  }
925
- return handleWebhookRequest({ req: toContextRequest(req, rawBody), ctx });
1095
+ return handleWebhookRequest({
1096
+ req: requestLike,
1097
+ ctx,
1098
+ rawBody,
1099
+ });
926
1100
  },
927
1101
  };
928
1102
  }
929
1103
  /**
930
1104
  * Create a Next.js POST handler for provider-verified payment webhooks.
931
1105
  *
932
- * The route extracts the provider signature header, reads the raw request
933
- * body as text, builds app context from the real request through
1106
+ * The route checks the provider signature header and bounds the raw request
1107
+ * body before resolving the server, then builds app context from a rehydrated
1108
+ * request through
934
1109
  * `server.createRequestContext(...)`, verifies the event through
935
1110
  * `ctx.ports.payments`, and then delegates fulfillment to app code. This is the canonical helper for Beignet
936
1111
  * billing flows backed by `PaymentsPort`. Keep this route on the Node.js
@@ -938,19 +1113,27 @@ export function createWebhookRoute(options) {
938
1113
  */
939
1114
  export function createPaymentWebhookRoute(options) {
940
1115
  const signatureHeader = options.signatureHeader ?? "stripe-signature";
941
- const handlePaymentWebhook = async ({ req, ctx, }) => {
1116
+ const maxBodyBytes = webhookBodyLimit(options.maxBodyBytes);
1117
+ const preflightBodies = new WeakMap();
1118
+ const handlePaymentWebhook = async ({ req, ctx, rawBody: providedRawBody, }) => {
942
1119
  const nativeReq = nativeRequestOf(req);
943
1120
  const headers = resolveHeaders(options.headers, nativeReq);
944
1121
  const signature = new Headers(req.headers).get(signatureHeader);
945
1122
  if (!signature) {
1123
+ cancelWebhookRequestBody(req, new Error(`Missing ${signatureHeader} header.`));
946
1124
  return operationalErrorJson("PAYMENT_WEBHOOK_SIGNATURE_MISSING", `Missing ${signatureHeader} header.`, 400, headers);
947
1125
  }
948
- let rawBody;
949
- try {
950
- rawBody = await req.text();
951
- }
952
- catch {
953
- return operationalErrorJson("PAYMENT_WEBHOOK_BODY_READ_FAILED", "Payment webhook body read failed.", 400, headers);
1126
+ let rawBody = providedRawBody ?? preflightBodies.get(nativeReq);
1127
+ preflightBodies.delete(nativeReq);
1128
+ if (rawBody === undefined) {
1129
+ try {
1130
+ rawBody = await readWebhookBody(req, maxBodyBytes);
1131
+ }
1132
+ catch (error) {
1133
+ return error instanceof WebhookBodyTooLargeError
1134
+ ? operationalErrorJson("PAYMENT_WEBHOOK_BODY_TOO_LARGE", "Payment webhook body exceeds the configured size limit.", 413, headers)
1135
+ : operationalErrorJson("PAYMENT_WEBHOOK_BODY_READ_FAILED", "Payment webhook body read failed.", 400, headers);
1136
+ }
954
1137
  }
955
1138
  let event;
956
1139
  try {
@@ -1013,32 +1196,44 @@ export function createPaymentWebhookRoute(options) {
1013
1196
  }, handlePaymentWebhook);
1014
1197
  return {
1015
1198
  POST: async (req) => {
1016
- const server = await resolveServerSource(options.server);
1017
- const pipelined = runPipeline(server);
1018
- if (pipelined)
1019
- return pipelined(req);
1020
- // Minimal servers without rawRoute(...) — usually test fakes — build
1021
- // context directly and run the same handler outside the hooks pipeline.
1022
- // The signature gate runs first so no context is created without one.
1023
1199
  const headers = resolveHeaders(options.headers, req);
1024
1200
  if (!req.headers.get(signatureHeader)) {
1201
+ if (req.body) {
1202
+ cancelWebhookBody(req.body, new Error(`Missing ${signatureHeader} header.`));
1203
+ }
1025
1204
  return operationalErrorJson("PAYMENT_WEBHOOK_SIGNATURE_MISSING", `Missing ${signatureHeader} header.`, 400, headers);
1026
1205
  }
1027
1206
  let rawBody;
1028
1207
  try {
1029
- rawBody = await req.text();
1208
+ rawBody = await readWebhookBody(toRequestLike(req), maxBodyBytes);
1030
1209
  }
1031
- catch {
1032
- return operationalErrorJson("PAYMENT_WEBHOOK_BODY_READ_FAILED", "Payment webhook body read failed.", 400, headers);
1210
+ catch (error) {
1211
+ return error instanceof WebhookBodyTooLargeError
1212
+ ? operationalErrorJson("PAYMENT_WEBHOOK_BODY_TOO_LARGE", "Payment webhook body exceeds the configured size limit.", 413, headers)
1213
+ : operationalErrorJson("PAYMENT_WEBHOOK_BODY_READ_FAILED", "Payment webhook body read failed.", 400, headers);
1033
1214
  }
1215
+ const contextRequest = rehydrateWebhookRequest(req, rawBody);
1216
+ const server = await resolveServerSource(options.server);
1217
+ const pipelined = runPipeline(server);
1218
+ if (pipelined) {
1219
+ preflightBodies.set(contextRequest, rawBody);
1220
+ return pipelined(contextRequest);
1221
+ }
1222
+ // Minimal servers without rawRoute(...) — usually test fakes — build
1223
+ // context directly and run the same handler outside the hooks pipeline.
1224
+ const requestLike = toRequestLike(contextRequest);
1034
1225
  let ctx;
1035
1226
  try {
1036
- ctx = await server.createRequestContext(toContextRequest(req, rawBody));
1227
+ ctx = await server.createRequestContext(requestLike);
1037
1228
  }
1038
1229
  catch {
1039
1230
  return operationalErrorJson("PAYMENT_WEBHOOK_CONTEXT_FAILED", "Payment webhook context failed.", 500, headers);
1040
1231
  }
1041
- return handlePaymentWebhook({ req: toContextRequest(req, rawBody), ctx });
1232
+ return handlePaymentWebhook({
1233
+ req: requestLike,
1234
+ ctx,
1235
+ rawBody,
1236
+ });
1042
1237
  },
1043
1238
  };
1044
1239
  }