@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.
- package/CHANGELOG.md +16 -0
- package/README.md +59 -19
- package/dist/application/index.d.ts +1 -1
- package/dist/application/index.d.ts.map +1 -1
- package/dist/application/index.js +5 -2
- package/dist/application/index.js.map +1 -1
- package/dist/events/index.d.ts +26 -2
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +84 -10
- package/dist/events/index.js.map +1 -1
- package/dist/events/payload-state.d.ts +14 -2
- package/dist/events/payload-state.d.ts.map +1 -1
- package/dist/events/payload-state.js +116 -4
- package/dist/events/payload-state.js.map +1 -1
- package/dist/events/transport.d.ts +25 -0
- package/dist/events/transport.d.ts.map +1 -0
- package/dist/events/transport.js +192 -0
- package/dist/events/transport.js.map +1 -0
- package/dist/openapi/index.d.ts.map +1 -1
- package/dist/openapi/index.js +27 -4
- package/dist/openapi/index.js.map +1 -1
- package/dist/outbox/index.d.ts.map +1 -1
- package/dist/outbox/index.js +10 -13
- package/dist/outbox/index.js.map +1 -1
- package/dist/ports/events.d.ts +4 -1
- package/dist/ports/events.d.ts.map +1 -1
- package/dist/ports/storage.d.ts +7 -0
- package/dist/ports/storage.d.ts.map +1 -1
- package/dist/ports/storage.js +4 -0
- package/dist/ports/storage.js.map +1 -1
- package/dist/ports/testing.d.ts +3 -2
- package/dist/ports/testing.d.ts.map +1 -1
- package/dist/ports/testing.js +7 -4
- package/dist/ports/testing.js.map +1 -1
- package/dist/ports/unit-of-work.d.ts +4 -4
- package/dist/ports/unit-of-work.d.ts.map +1 -1
- package/dist/ports/unit-of-work.js +8 -9
- package/dist/ports/unit-of-work.js.map +1 -1
- package/dist/search/index.js +2 -2
- package/dist/search/index.js.map +1 -1
- package/dist/server/instrumentation.d.ts +5 -5
- package/dist/server/instrumentation.d.ts.map +1 -1
- package/dist/server/instrumentation.js +3 -4
- package/dist/server/instrumentation.js.map +1 -1
- package/dist/server/response-finalization.d.ts.map +1 -1
- package/dist/server/response-finalization.js +25 -13
- package/dist/server/response-finalization.js.map +1 -1
- package/dist/server/server.d.ts +8 -5
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1 -3
- package/dist/server/server.js.map +1 -1
- package/dist/uploads/index.d.ts.map +1 -1
- package/dist/uploads/index.js +37 -16
- package/dist/uploads/index.js.map +1 -1
- package/package.json +2 -2
- package/skills/app-architecture/SKILL.md +7 -5
- package/src/application/index.ts +24 -3
- package/src/events/index.ts +137 -10
- package/src/events/payload-state.ts +205 -6
- package/src/events/transport.ts +242 -0
- package/src/openapi/index.ts +41 -3
- package/src/outbox/index.ts +26 -18
- package/src/ports/events.ts +4 -1
- package/src/ports/storage.ts +10 -0
- package/src/ports/testing.ts +11 -4
- package/src/ports/unit-of-work.ts +20 -16
- package/src/search/index.ts +2 -2
- package/src/server/instrumentation.ts +10 -8
- package/src/server/response-finalization.ts +33 -15
- package/src/server/server.ts +9 -8
- package/src/uploads/index.ts +34 -16
package/src/openapi/index.ts
CHANGED
|
@@ -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
|
}
|
package/src/outbox/index.ts
CHANGED
|
@@ -8,13 +8,10 @@
|
|
|
8
8
|
import {
|
|
9
9
|
type EventPayloadDef,
|
|
10
10
|
type EventPublishOptions,
|
|
11
|
+
type EventTransportValue,
|
|
11
12
|
type InferEventPayload,
|
|
12
|
-
|
|
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
|
|
1512
|
-
return await
|
|
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
|
|
1517
|
+
async function enqueueTransportEvent<E extends EventPayloadDef>(
|
|
1516
1518
|
outbox: OutboxPort,
|
|
1517
1519
|
event: E,
|
|
1518
|
-
payload:
|
|
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
|
|
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
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1687
|
+
prepared.payload,
|
|
1688
|
+
prepared.publishOptions,
|
|
1681
1689
|
);
|
|
1682
1690
|
return;
|
|
1683
1691
|
}
|
package/src/ports/events.ts
CHANGED
|
@@ -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
|
package/src/ports/storage.ts
CHANGED
|
@@ -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
|
},
|
package/src/ports/testing.ts
CHANGED
|
@@ -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
|
|
85
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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))
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
269
|
+
prepared.payload as never,
|
|
270
|
+
prepared.publishOptions,
|
|
267
271
|
);
|
|
268
272
|
records.shift();
|
|
269
273
|
}
|
package/src/search/index.ts
CHANGED
|
@@ -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.
|
|
438
|
+
summary: `${index.name}: ${result.hits.length} hits`,
|
|
439
439
|
details: {
|
|
440
440
|
index: index.name,
|
|
441
|
-
|
|
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
|
|
65
|
-
*
|
|
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
|
|
129
|
-
*
|
|
128
|
+
* Pipeline hook installed before user hooks. It always owns ambient
|
|
129
|
+
* correlation; response headers and event recording remain configurable.
|
|
130
130
|
*/
|
|
131
|
-
hook
|
|
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 (
|
|
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
|
|
507
|
+
hook,
|
|
506
508
|
};
|
|
507
509
|
}
|
|
@@ -312,18 +312,18 @@ function getDeclaredCatalogErrorsForStatus(
|
|
|
312
312
|
);
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
async function
|
|
315
|
+
async function parseCatalogErrorResponse<C extends HttpContractConfig>(
|
|
316
316
|
contract: C,
|
|
317
317
|
res: HttpResponseLike,
|
|
318
|
-
): Promise<
|
|
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(
|
|
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
|
|
378
|
+
async function parseResponseAgainstContract<C extends HttpContractConfig>(
|
|
366
379
|
contract: C,
|
|
367
380
|
res: HttpResponseLike,
|
|
368
381
|
responseValidationExemptStatus?: number,
|
|
369
|
-
): Promise<
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
518
|
+
exposeErrorDetails && err instanceof Error
|
|
501
519
|
? {
|
|
502
520
|
error: {
|
|
503
521
|
message: err.message,
|
package/src/server/server.ts
CHANGED
|
@@ -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
|
|
212
|
-
*
|
|
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
|
|
215
|
-
* client-side
|
|
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
|
-
|
|
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) : [];
|
package/src/uploads/index.ts
CHANGED
|
@@ -968,29 +968,47 @@ export function createUploadRouter<Ctx>(
|
|
|
968
968
|
},
|
|
969
969
|
});
|
|
970
970
|
}
|
|
971
|
-
const
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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 });
|