@beignet/core 0.0.51 → 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.
Files changed (71) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +59 -19
  3. package/dist/application/index.d.ts +1 -1
  4. package/dist/application/index.d.ts.map +1 -1
  5. package/dist/application/index.js +5 -2
  6. package/dist/application/index.js.map +1 -1
  7. package/dist/events/index.d.ts +26 -2
  8. package/dist/events/index.d.ts.map +1 -1
  9. package/dist/events/index.js +84 -10
  10. package/dist/events/index.js.map +1 -1
  11. package/dist/events/payload-state.d.ts +14 -2
  12. package/dist/events/payload-state.d.ts.map +1 -1
  13. package/dist/events/payload-state.js +116 -4
  14. package/dist/events/payload-state.js.map +1 -1
  15. package/dist/events/transport.d.ts +25 -0
  16. package/dist/events/transport.d.ts.map +1 -0
  17. package/dist/events/transport.js +192 -0
  18. package/dist/events/transport.js.map +1 -0
  19. package/dist/openapi/index.d.ts.map +1 -1
  20. package/dist/openapi/index.js +27 -4
  21. package/dist/openapi/index.js.map +1 -1
  22. package/dist/outbox/index.d.ts.map +1 -1
  23. package/dist/outbox/index.js +10 -13
  24. package/dist/outbox/index.js.map +1 -1
  25. package/dist/ports/events.d.ts +4 -1
  26. package/dist/ports/events.d.ts.map +1 -1
  27. package/dist/ports/storage.d.ts +7 -0
  28. package/dist/ports/storage.d.ts.map +1 -1
  29. package/dist/ports/storage.js +4 -0
  30. package/dist/ports/storage.js.map +1 -1
  31. package/dist/ports/testing.d.ts +3 -2
  32. package/dist/ports/testing.d.ts.map +1 -1
  33. package/dist/ports/testing.js +7 -4
  34. package/dist/ports/testing.js.map +1 -1
  35. package/dist/ports/unit-of-work.d.ts +4 -4
  36. package/dist/ports/unit-of-work.d.ts.map +1 -1
  37. package/dist/ports/unit-of-work.js +8 -9
  38. package/dist/ports/unit-of-work.js.map +1 -1
  39. package/dist/search/index.js +2 -2
  40. package/dist/search/index.js.map +1 -1
  41. package/dist/server/instrumentation.d.ts +5 -5
  42. package/dist/server/instrumentation.d.ts.map +1 -1
  43. package/dist/server/instrumentation.js +3 -4
  44. package/dist/server/instrumentation.js.map +1 -1
  45. package/dist/server/response-finalization.d.ts.map +1 -1
  46. package/dist/server/response-finalization.js +25 -13
  47. package/dist/server/response-finalization.js.map +1 -1
  48. package/dist/server/server.d.ts +8 -5
  49. package/dist/server/server.d.ts.map +1 -1
  50. package/dist/server/server.js +1 -3
  51. package/dist/server/server.js.map +1 -1
  52. package/dist/uploads/index.d.ts.map +1 -1
  53. package/dist/uploads/index.js +37 -16
  54. package/dist/uploads/index.js.map +1 -1
  55. package/package.json +2 -2
  56. package/skills/app-architecture/SKILL.md +7 -5
  57. package/src/application/index.ts +24 -3
  58. package/src/events/index.ts +137 -10
  59. package/src/events/payload-state.ts +205 -6
  60. package/src/events/transport.ts +242 -0
  61. package/src/openapi/index.ts +41 -3
  62. package/src/outbox/index.ts +26 -18
  63. package/src/ports/events.ts +4 -1
  64. package/src/ports/storage.ts +10 -0
  65. package/src/ports/testing.ts +11 -4
  66. package/src/ports/unit-of-work.ts +20 -16
  67. package/src/search/index.ts +2 -2
  68. package/src/server/instrumentation.ts +10 -8
  69. package/src/server/response-finalization.ts +33 -15
  70. package/src/server/server.ts +9 -8
  71. package/src/uploads/index.ts +34 -16
@@ -436,20 +436,58 @@ export function contractsToOpenAPI(
436
436
  ],
437
437
  };
438
438
  const operationIds = new Map<string, string>();
439
+ const operations = new Map<
440
+ string,
441
+ { readonly contractName: string; readonly route: string }
442
+ >();
443
+ const pathShapes = new Map<
444
+ string,
445
+ {
446
+ readonly contractName: string;
447
+ readonly pathKey: string;
448
+ readonly route: string;
449
+ }
450
+ >();
439
451
 
440
452
  for (const contract of contracts) {
441
453
  const config = resolveContract(contract);
442
454
  assertValidContractLifecycle(config);
455
+ const pathTemplate = parsePathTemplate(config.path);
456
+ const pathKey = pathTemplate.openApiPath;
457
+ const route = `${config.method.toUpperCase()} ${config.path}`;
458
+ const operationRoute = `${config.method.toUpperCase()} ${pathKey}`;
459
+ const conflictingOperation = operations.get(operationRoute);
460
+ if (conflictingOperation) {
461
+ throw new Error(
462
+ `Duplicate OpenAPI operation: ${operationRoute} is produced by both contract "${conflictingOperation.contractName}" (${conflictingOperation.route}) and contract "${config.name}" (${route}). Each method + normalized path combination must be unique within an OpenAPI document.`,
463
+ );
464
+ }
465
+ const conflictingPathShape = pathShapes.get(pathTemplate.shapeKey);
466
+ if (conflictingPathShape && conflictingPathShape.pathKey !== pathKey) {
467
+ throw new Error(
468
+ `Ambiguous OpenAPI path: ${pathKey} from contract "${config.name}" (${route}) conflicts with ${conflictingPathShape.pathKey} from contract "${conflictingPathShape.contractName}" (${conflictingPathShape.route}). Templated paths with the same hierarchy must use the same parameter names within an OpenAPI document.`,
469
+ );
470
+ }
443
471
  const operationId = getContractOperationId(config);
444
- const route = `${config.method} ${config.path}`;
445
472
  const conflictingRoute = operationIds.get(operationId);
446
473
  if (conflictingRoute) {
447
474
  throw new Error(
448
475
  `Duplicate OpenAPI operationId: "${operationId}" is used by both ${conflictingRoute} and ${route}. Operation IDs must be unique within an OpenAPI document.`,
449
476
  );
450
477
  }
478
+ operations.set(operationRoute, {
479
+ contractName: config.name,
480
+ route,
481
+ });
482
+ if (!conflictingPathShape) {
483
+ pathShapes.set(pathTemplate.shapeKey, {
484
+ contractName: config.name,
485
+ pathKey,
486
+ route,
487
+ });
488
+ }
451
489
  operationIds.set(operationId, route);
452
- addContractToPaths(config, paths, state);
490
+ addContractToPaths(config, pathKey, paths, state);
453
491
  }
454
492
 
455
493
  const openapi: OpenAPIObject = {
@@ -481,10 +519,10 @@ export function contractsToOpenAPI(
481
519
  */
482
520
  function addContractToPaths(
483
521
  contract: AnyContract,
522
+ pathKey: string,
484
523
  paths: PathsObject,
485
524
  state: GeneratorState,
486
525
  ): void {
487
- const pathKey = parsePathTemplate(contract.path).openApiPath;
488
526
  if (!paths[pathKey]) {
489
527
  paths[pathKey] = {};
490
528
  }
@@ -8,13 +8,10 @@
8
8
  import {
9
9
  type EventPayloadDef,
10
10
  type EventPublishOptions,
11
+ type EventTransportValue,
11
12
  type InferEventPayload,
12
- parseEventPayload,
13
+ prepareEventPayloadForTransport,
13
14
  } from "../events/index.js";
14
- import {
15
- isEventPayloadParsed,
16
- markEventPayloadParsed,
17
- } from "../events/payload-state.js";
18
15
  import {
19
16
  getJobRetryDelayMs,
20
17
  getJobRetryMaxAttempts,
@@ -1508,14 +1505,19 @@ export async function enqueueEvent<E extends EventPayloadDef>(
1508
1505
  payload: InferEventPayload<E>,
1509
1506
  options: EnqueueTypedOutboxOptions = {},
1510
1507
  ): Promise<OutboxMessage> {
1511
- const parsed = await parseEventPayload(event, payload);
1512
- return await enqueueParsedEvent(outbox, event, parsed, options);
1508
+ const prepared = await prepareEventPayloadForTransport(event, payload);
1509
+ return await enqueueTransportEvent(
1510
+ outbox,
1511
+ event,
1512
+ prepared.transportValue,
1513
+ options,
1514
+ );
1513
1515
  }
1514
1516
 
1515
- async function enqueueParsedEvent<E extends EventPayloadDef>(
1517
+ async function enqueueTransportEvent<E extends EventPayloadDef>(
1516
1518
  outbox: OutboxPort,
1517
1519
  event: E,
1518
- payload: InferEventPayload<E>,
1520
+ payload: EventTransportValue,
1519
1521
  options: EnqueueTypedOutboxOptions,
1520
1522
  ): Promise<OutboxMessage> {
1521
1523
  const trace =
@@ -1524,7 +1526,7 @@ async function enqueueParsedEvent<E extends EventPayloadDef>(
1524
1526
  id: options.id,
1525
1527
  kind: "event",
1526
1528
  name: event.name,
1527
- payload: toOutboxJsonValue(payload),
1529
+ payload,
1528
1530
  trace,
1529
1531
  availableAt: options.availableAt,
1530
1532
  maxAttempts: options.maxAttempts,
@@ -1563,12 +1565,14 @@ export function createOutboxEventRecorder(
1563
1565
  ): DomainEventRecorderPort {
1564
1566
  return {
1565
1567
  async record(event, payload, publishOptions) {
1566
- const parsed = isEventPayloadParsed(publishOptions)
1567
- ? payload
1568
- : await parseEventPayload(event, payload);
1569
- await enqueueParsedEvent(outbox, event, parsed, {
1568
+ const prepared = await prepareEventPayloadForTransport(
1569
+ event,
1570
+ payload,
1571
+ publishOptions,
1572
+ );
1573
+ await enqueueTransportEvent(outbox, event, prepared.transportValue, {
1570
1574
  ...options,
1571
- trace: publishOptions?.trace ?? options.trace,
1575
+ trace: prepared.publishOptions.trace ?? options.trace,
1572
1576
  });
1573
1577
  },
1574
1578
  };
@@ -1673,11 +1677,15 @@ async function deliverOutboxMessage(
1673
1677
  );
1674
1678
  }
1675
1679
 
1676
- const payload = await parseEventPayload(event, message.payload);
1680
+ const prepared = await prepareEventPayloadForTransport(
1681
+ event,
1682
+ message.payload,
1683
+ trace ? { trace } : undefined,
1684
+ );
1677
1685
  await options.eventBus.publish(
1678
1686
  event,
1679
- payload,
1680
- markEventPayloadParsed(trace ? { trace } : undefined),
1687
+ prepared.payload,
1688
+ prepared.publishOptions,
1681
1689
  );
1682
1690
  return;
1683
1691
  }
@@ -53,7 +53,10 @@ export type InferJobPayload<J extends JobDef> = InferContractJobPayload<J>;
53
53
  * An EventBus port for publishing and subscribing to domain events.
54
54
  *
55
55
  * This interface defines a framework-agnostic contract for event-driven
56
- * communication within your application.
56
+ * communication within your application. Implementations must prepare
57
+ * producer payloads with `prepareEventPayloadForTransport(...)` from
58
+ * `@beignet/core/events` so direct publication and provider swaps preserve the
59
+ * same canonical JSON semantics.
57
60
  *
58
61
  * @example
59
62
  * ```ts
@@ -82,6 +82,13 @@ export interface StorageObjectBody extends StorageObject {
82
82
  * buffering them.
83
83
  */
84
84
  readonly bodyUsed: boolean;
85
+ /**
86
+ * Discard an unread body and release any resources held by its provider.
87
+ *
88
+ * Calling this after consumption has started is a no-op. Callers that only
89
+ * inspect object metadata should cancel the body in a `finally` block.
90
+ */
91
+ cancel(reason?: unknown): Promise<void>;
85
92
  /**
86
93
  * Consume the object as a readable byte stream.
87
94
  */
@@ -376,6 +383,9 @@ function createObjectBody(entry: MemoryStorageEntry): StorageObjectBody {
376
383
  get bodyUsed() {
377
384
  return bodyUsed;
378
385
  },
386
+ async cancel() {
387
+ if (!bodyUsed) bodyUsed = true;
388
+ },
379
389
  stream() {
380
390
  return bytesToStream(consumeBytes());
381
391
  },
@@ -1,3 +1,4 @@
1
+ import { prepareEventPayloadForTransport } from "../events/index.js";
1
2
  import type {
2
3
  MemoryIdempotencyEntry,
3
4
  MemoryIdempotencyStore,
@@ -81,8 +82,9 @@ export interface RecordedEventExpectation {
81
82
  /**
82
83
  * Create a recording event bus for testing.
83
84
  *
84
- * This bus records all published events for later assertion,
85
- * but does not support subscription (throws if called).
85
+ * This bus validates canonical transport output and records published events
86
+ * asynchronously for later assertion. Await `publish(...)` before reading the
87
+ * captured log. Subscription is not supported and throws when called.
86
88
  *
87
89
  * @example
88
90
  * ```ts
@@ -104,8 +106,13 @@ export function createRecordingEventBus(): {
104
106
  const events: RecordedEvent[] = [];
105
107
 
106
108
  const bus: EventBusPort = {
107
- publish(event, payload) {
108
- events.push({ name: event.name, payload });
109
+ async publish(event, payload, options) {
110
+ const prepared = await prepareEventPayloadForTransport(
111
+ event,
112
+ payload,
113
+ options,
114
+ );
115
+ events.push({ name: event.name, payload: prepared.payload });
109
116
  },
110
117
  subscribe() {
111
118
  throw new Error("Not implemented for recording bus");
@@ -1,11 +1,8 @@
1
1
  import {
2
2
  type EventPublishOptions,
3
- parseEventPayload,
3
+ prepareEventPayloadForTransport,
4
4
  } from "../events/index.js";
5
- import {
6
- isEventPayloadParsed,
7
- markEventPayloadParsed,
8
- } from "../events/payload-state.js";
5
+ import { isEventPayloadParsed } from "../events/payload-state.js";
9
6
  import type {
10
7
  DomainEventDef,
11
8
  EventBusPort,
@@ -93,8 +90,8 @@ export interface RecordedDomainEvent {
93
90
  */
94
91
  readonly eventName: string;
95
92
  /**
96
- * Recorded payload. Use-case helpers store parsed schema output; direct
97
- * recorder calls are validated when the buffer is flushed.
93
+ * Recorded payload. Use-case helpers store canonical transport-stable schema
94
+ * output; direct recorder calls are validated when the buffer is flushed.
98
95
  */
99
96
  readonly payload: unknown;
100
97
  /** Optional metadata propagated when the event is flushed. */
@@ -132,8 +129,8 @@ export interface BufferedDomainEventRecorder extends DomainEventRecorderPort {
132
129
  */
133
130
  clear(): void;
134
131
  /**
135
- * Publish recorded events in FIFO order, validating entries recorded
136
- * directly without the use-case event helper.
132
+ * Publish recorded events in FIFO order, proving transport stability for
133
+ * entries recorded directly without the use-case event helper.
137
134
  */
138
135
  flush(eventBus: EventBusPort): Promise<void>;
139
136
  }
@@ -229,7 +226,10 @@ export function createObservedUnitOfWork<TxPorts>(
229
226
  */
230
227
  export function createDomainEventRecorder(): BufferedDomainEventRecorder {
231
228
  const records: RecordedDomainEvent[] = [];
232
- const parsedRecords = new WeakSet<RecordedDomainEvent>();
229
+ const validationOptions = new WeakMap<
230
+ RecordedDomainEvent,
231
+ EventPublishOptions
232
+ >();
233
233
 
234
234
  return {
235
235
  record(event, payload, options) {
@@ -239,7 +239,9 @@ export function createDomainEventRecorder(): BufferedDomainEventRecorder {
239
239
  payload,
240
240
  ...(options?.trace ? { options: { trace: options.trace } } : {}),
241
241
  };
242
- if (isEventPayloadParsed(options)) parsedRecords.add(record);
242
+ if (isEventPayloadParsed(event, payload, options) && options) {
243
+ validationOptions.set(record, { ...options });
244
+ }
243
245
  records.push(record);
244
246
  },
245
247
 
@@ -257,13 +259,15 @@ export function createDomainEventRecorder(): BufferedDomainEventRecorder {
257
259
  async flush(eventBus) {
258
260
  while (records.length > 0) {
259
261
  const record = records[0];
260
- const payload = parsedRecords.has(record)
261
- ? record.payload
262
- : await parseEventPayload(record.event, record.payload);
262
+ const prepared = await prepareEventPayloadForTransport(
263
+ record.event,
264
+ record.payload,
265
+ validationOptions.get(record) ?? record.options,
266
+ );
263
267
  await eventBus.publish(
264
268
  record.event,
265
- payload as never,
266
- markEventPayloadParsed(record.options),
269
+ prepared.payload as never,
270
+ prepared.publishOptions,
267
271
  );
268
272
  records.shift();
269
273
  }
@@ -435,10 +435,10 @@ function instrumentSearch(
435
435
  instrumentation.custom({
436
436
  name: "search.query",
437
437
  label: "Search query",
438
- summary: `${index.name}: ${result.query}`,
438
+ summary: `${index.name}: ${result.hits.length} hits`,
439
439
  details: {
440
440
  index: index.name,
441
- query: result.query,
441
+ queryLength: result.query.length,
442
442
  hits: result.hits.length,
443
443
  total: result.page.total,
444
444
  durationMs: Date.now() - startedAt,
@@ -61,8 +61,8 @@ export interface ServerInstrumentationOptions<Ctx = unknown> {
61
61
  traceContextHeader?: string | false;
62
62
 
63
63
  /**
64
- * Request path prefixes that should not enter ambient correlation or record
65
- * events. Response headers are still written.
64
+ * Request path prefixes that should not record instrumentation events.
65
+ * Ambient correlation still runs, and enabled response headers are written.
66
66
  *
67
67
  * Defaults to the devtools dashboard prefix so its polling traffic does not
68
68
  * fill the event timeline.
@@ -125,10 +125,10 @@ export interface ServerInstrumentationRuntime<Ctx> {
125
125
  */
126
126
  createServiceCorrelation(): RequestCorrelation;
127
127
  /**
128
- * Pipeline hook installed before user hooks, when instrumentation is
129
- * enabled.
128
+ * Pipeline hook installed before user hooks. It always owns ambient
129
+ * correlation; response headers and event recording remain configurable.
130
130
  */
131
- hook?: ServerHook<Ctx, AnyPorts>;
131
+ hook: ServerHook<Ctx, AnyPorts>;
132
132
  }
133
133
 
134
134
  type TraceContextFields = {
@@ -350,7 +350,6 @@ export function createServerInstrumentation<Ctx>(
350
350
  req: HttpRequestLike;
351
351
  ctx?: unknown;
352
352
  }) => {
353
- if (isIgnoredPath(getPathname(args.req), ignorePaths)) return;
354
353
  const trace = resolveTraceContext(args);
355
354
  enterActiveRequestContext({
356
355
  requestId: resolveRequestId(args),
@@ -377,7 +376,10 @@ export function createServerInstrumentation<Ctx>(
377
376
  return undefined;
378
377
  },
379
378
  beforeSend: ({ req, ctx, response }) => {
380
- if (requestIdHeader === false && traceContextHeader === false) {
379
+ if (
380
+ !enabled ||
381
+ (requestIdHeader === false && traceContextHeader === false)
382
+ ) {
381
383
  return undefined;
382
384
  }
383
385
 
@@ -502,6 +504,6 @@ export function createServerInstrumentation<Ctx>(
502
504
  requestId: createRequestId(),
503
505
  trace: tracing?.current() ?? createTraceContext(),
504
506
  }),
505
- hook: enabled ? hook : undefined,
507
+ hook,
506
508
  };
507
509
  }
@@ -312,18 +312,18 @@ function getDeclaredCatalogErrorsForStatus(
312
312
  );
313
313
  }
314
314
 
315
- async function validateCatalogErrorResponse<C extends HttpContractConfig>(
315
+ async function parseCatalogErrorResponse<C extends HttpContractConfig>(
316
316
  contract: C,
317
317
  res: HttpResponseLike,
318
- ): Promise<void> {
318
+ ): Promise<HttpResponseLike> {
319
319
  const body = res.body;
320
- if (res.status < 400 || !isErrorResponseBody(body)) return;
320
+ if (res.status < 400 || !isErrorResponseBody(body)) return res;
321
321
 
322
322
  const declaredErrors = getDeclaredCatalogErrorsForStatus(
323
323
  contract,
324
324
  res.status,
325
325
  );
326
- if (declaredErrors.length === 0) return;
326
+ if (declaredErrors.length === 0) return res;
327
327
 
328
328
  const matchingError = declaredErrors.find(
329
329
  (error) => error.code === body.code,
@@ -346,7 +346,18 @@ async function validateCatalogErrorResponse<C extends HttpContractConfig>(
346
346
 
347
347
  if (matchingError.details && body.details !== undefined) {
348
348
  try {
349
- await parseStandardSchema(matchingError.details, body.details);
349
+ const parsedDetails = await parseStandardSchema(
350
+ matchingError.details,
351
+ body.details,
352
+ );
353
+ const { details: _details, ...bodyWithoutDetails } = body;
354
+ return {
355
+ ...res,
356
+ body:
357
+ parsedDetails === undefined
358
+ ? bodyWithoutDetails
359
+ : { ...bodyWithoutDetails, details: parsedDetails },
360
+ };
350
361
  } catch (error) {
351
362
  if (error instanceof SchemaValidationError) {
352
363
  throw new ResponseContractViolationError({
@@ -360,18 +371,20 @@ async function validateCatalogErrorResponse<C extends HttpContractConfig>(
360
371
  throw error;
361
372
  }
362
373
  }
374
+
375
+ return res;
363
376
  }
364
377
 
365
- async function validateResponseAgainstContract<C extends HttpContractConfig>(
378
+ async function parseResponseAgainstContract<C extends HttpContractConfig>(
366
379
  contract: C,
367
380
  res: HttpResponseLike,
368
381
  responseValidationExemptStatus?: number,
369
- ): Promise<void> {
382
+ ): Promise<HttpResponseLike> {
370
383
  const statusKey = String(res.status);
371
384
  const hasDeclaredStatus = Object.hasOwn(contract.responses, statusKey);
372
385
 
373
386
  if (!hasDeclaredStatus) {
374
- if (Object.keys(contract.responses).length === 0) return;
387
+ if (Object.keys(contract.responses).length === 0) return res;
375
388
 
376
389
  throw new ResponseContractViolationError({
377
390
  code: "UNDECLARED_RESPONSE_STATUS",
@@ -400,19 +413,22 @@ async function validateResponseAgainstContract<C extends HttpContractConfig>(
400
413
  }),
401
414
  });
402
415
  }
403
- return;
416
+ return res;
404
417
  }
405
418
 
406
- if (!responseSchema) return;
419
+ if (!responseSchema) return res;
407
420
 
408
421
  // Binder routes whose use case output schema is the same object as the
409
422
  // declared success response schema skip the redundant success-status parse.
410
423
  // Error statuses and undeclared statuses are validated unchanged.
411
- if (res.status === responseValidationExemptStatus) return;
424
+ if (res.status === responseValidationExemptStatus) return res;
412
425
 
413
426
  try {
414
- await parseStandardSchema(responseSchema, res.body);
415
- await validateCatalogErrorResponse(contract, res);
427
+ const parsed = {
428
+ ...res,
429
+ body: await parseStandardSchema(responseSchema, res.body),
430
+ };
431
+ return await parseCatalogErrorResponse(contract, parsed);
416
432
  } catch (error) {
417
433
  if (error instanceof SchemaValidationError) {
418
434
  throw new ResponseContractViolationError({
@@ -463,7 +479,7 @@ export async function finalizeResponse<C extends HttpContractConfig>(
463
479
  const normalized = normalizeResponse(res);
464
480
  validateHttpResponseSemantics(contract, normalized);
465
481
  if (options.validateContract ?? true) {
466
- await validateResponseAgainstContract(
482
+ return parseResponseAgainstContract(
467
483
  contract,
468
484
  normalized,
469
485
  responseValidationExemptStatus,
@@ -490,6 +506,8 @@ export function defaultErrorResponse(
490
506
  ctx?: unknown,
491
507
  ): HttpResponseLike {
492
508
  const requestId = getRequestIdFromContext(ctx);
509
+ const exposeErrorDetails =
510
+ process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
493
511
  return {
494
512
  status: 500,
495
513
  body: createErrorResponseBody({
@@ -497,7 +515,7 @@ export function defaultErrorResponse(
497
515
  message: "Internal server error",
498
516
  requestId,
499
517
  details:
500
- process.env.NODE_ENV !== "production" && err instanceof Error
518
+ exposeErrorDetails && err instanceof Error
501
519
  ? {
502
520
  error: {
503
521
  message: err.message,
@@ -204,15 +204,18 @@ export type CreateServerOptions<
204
204
  * `ports.devtools`) when one is installed.
205
205
  *
206
206
  * Pass `false` to disable headers and event recording. Context factories
207
- * still receive `requestId` and `trace` arguments.
207
+ * still receive `requestId` and `trace` arguments, and request-scoped
208
+ * correlation remains available to ambient audit wrappers.
208
209
  */
209
210
  instrumentation?: ServerInstrumentationOptions<Ctx> | false;
210
211
  /**
211
- * Whether route-owned responses are validated against the contract's
212
- * declared statuses and response schemas before they are sent.
212
+ * Whether route-owned responses are parsed against the contract's declared
213
+ * statuses and response schemas before they are sent. The parsed schema
214
+ * output becomes the response body.
213
215
  *
214
- * Disable this to trade response guarantees for throughput, mirroring the
215
- * client-side `validateResponses` option.
216
+ * Disable this to send route-owned handler bodies as-is without validation,
217
+ * unknown-key stripping, or transforms, mirroring the client-side
218
+ * `validateResponses` option.
216
219
  *
217
220
  * @default true
218
221
  */
@@ -407,9 +410,7 @@ export async function createServer<
407
410
  options.instrumentation,
408
411
  );
409
412
  const hooks = [
410
- ...(instrumentation.hook
411
- ? [instrumentation.hook as ServerHook<Ctx, FinalPorts>]
412
- : []),
413
+ instrumentation.hook as ServerHook<Ctx, FinalPorts>,
413
414
  ...((options.hooks ?? []) as ServerHook<Ctx, FinalPorts>[]),
414
415
  ];
415
416
  const contracts = options.routes ? contractsFromRoutes(options.routes) : [];
@@ -968,29 +968,47 @@ export function createUploadRouter<Ctx>(
968
968
  },
969
969
  });
970
970
  }
971
- const object = needsUploadBodyVerification(upload, file)
971
+ const verifyBody = needsUploadBodyVerification(upload, file);
972
+ const objectBody = verifyBody
972
973
  ? await options.storage.get(file.key)
974
+ : null;
975
+ const object = verifyBody
976
+ ? objectBody
973
977
  : await options.storage.stat(file.key);
974
978
  if (!object) {
975
979
  throw new UploadObjectNotFoundError({
976
980
  message: `Uploaded object "${file.key}" was not found.`,
977
981
  });
978
982
  }
979
- assertStoredObject(upload, file, object);
980
- const verified = await verifyStoredUploadFile(upload, file, object);
981
- const completedObject = storageObjectMetadata(object);
982
- const completedFile = {
983
- ...file,
984
- ...(verified.checksum ? { checksum: verified.checksum } : {}),
985
- object: completedObject,
986
- };
987
- await assertVerifiedFile(upload, {
988
- ctx,
989
- metadata,
990
- file: completedFile,
991
- storage: options.storage,
992
- });
993
- files.push(completedFile);
983
+ let operationFailed = false;
984
+ try {
985
+ assertStoredObject(upload, file, object);
986
+ const verified = await verifyStoredUploadFile(upload, file, object);
987
+ const completedObject = storageObjectMetadata(object);
988
+ const completedFile = {
989
+ ...file,
990
+ ...(verified.checksum ? { checksum: verified.checksum } : {}),
991
+ object: completedObject,
992
+ };
993
+ await assertVerifiedFile(upload, {
994
+ ctx,
995
+ metadata,
996
+ file: completedFile,
997
+ storage: options.storage,
998
+ });
999
+ files.push(completedFile);
1000
+ } catch (error) {
1001
+ operationFailed = true;
1002
+ throw error;
1003
+ } finally {
1004
+ if (objectBody && !objectBody.bodyUsed) {
1005
+ if (operationFailed) {
1006
+ await objectBody.cancel().catch(() => undefined);
1007
+ } else {
1008
+ await objectBody.cancel();
1009
+ }
1010
+ }
1011
+ }
994
1012
  }
995
1013
 
996
1014
  const result = await upload.onComplete?.({ ctx, metadata, files });