@beignet/core 0.0.36 → 0.0.38
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 +19 -0
- package/README.md +135 -19
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +6 -28
- package/dist/client/client.js.map +1 -1
- package/dist/contracts/contract-builder.d.ts +7 -0
- package/dist/contracts/contract-builder.d.ts.map +1 -1
- package/dist/contracts/contract-builder.js +22 -0
- package/dist/contracts/contract-builder.js.map +1 -1
- package/dist/contracts/contract-group.d.ts +7 -0
- package/dist/contracts/contract-group.d.ts.map +1 -1
- package/dist/contracts/contract-group.js +14 -0
- package/dist/contracts/contract-group.js.map +1 -1
- package/dist/contracts/index.d.ts +4 -0
- package/dist/contracts/index.d.ts.map +1 -1
- package/dist/contracts/index.js +4 -0
- package/dist/contracts/index.js.map +1 -1
- package/dist/contracts/lifecycle.d.ts +40 -0
- package/dist/contracts/lifecycle.d.ts.map +1 -0
- package/dist/contracts/lifecycle.js +174 -0
- package/dist/contracts/lifecycle.js.map +1 -0
- package/dist/contracts/types.d.ts +5 -0
- package/dist/contracts/types.d.ts.map +1 -1
- package/dist/contracts/types.js.map +1 -1
- package/dist/error-reporting/index.d.ts +42 -1
- package/dist/error-reporting/index.d.ts.map +1 -1
- package/dist/error-reporting/index.js +60 -0
- package/dist/error-reporting/index.js.map +1 -1
- package/dist/error-reporting/internal.d.ts +11 -0
- package/dist/error-reporting/internal.d.ts.map +1 -0
- package/dist/error-reporting/internal.js +36 -0
- package/dist/error-reporting/internal.js.map +1 -0
- package/dist/events/index.d.ts +9 -4
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +5 -3
- package/dist/events/index.js.map +1 -1
- package/dist/idempotency/index.d.ts +25 -2
- package/dist/idempotency/index.d.ts.map +1 -1
- package/dist/idempotency/index.js +35 -5
- package/dist/idempotency/index.js.map +1 -1
- package/dist/jobs/index.d.ts +26 -4
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js +51 -6
- package/dist/jobs/index.js.map +1 -1
- package/dist/locks/index.d.ts +19 -1
- package/dist/locks/index.d.ts.map +1 -1
- package/dist/locks/index.js +27 -12
- package/dist/locks/index.js.map +1 -1
- package/dist/openapi/index.d.ts +3 -1
- package/dist/openapi/index.d.ts.map +1 -1
- package/dist/openapi/index.js +14 -2
- package/dist/openapi/index.js.map +1 -1
- package/dist/outbox/index.d.ts +23 -4
- package/dist/outbox/index.d.ts.map +1 -1
- package/dist/outbox/index.js +71 -23
- package/dist/outbox/index.js.map +1 -1
- package/dist/ports/events.d.ts +5 -5
- package/dist/ports/events.d.ts.map +1 -1
- package/dist/ports/index.d.ts +4 -4
- package/dist/ports/unbound.js +1 -1
- package/dist/ports/unbound.js.map +1 -1
- package/dist/ports/unit-of-work.d.ts +4 -1
- package/dist/ports/unit-of-work.d.ts.map +1 -1
- package/dist/ports/unit-of-work.js +4 -3
- package/dist/ports/unit-of-work.js.map +1 -1
- package/dist/query-codec.d.ts +3 -0
- package/dist/query-codec.d.ts.map +1 -0
- package/dist/query-codec.js +110 -0
- package/dist/query-codec.js.map +1 -0
- package/dist/server/hooks/cors.d.ts +3 -2
- package/dist/server/hooks/cors.d.ts.map +1 -1
- package/dist/server/hooks/cors.js +7 -3
- package/dist/server/hooks/cors.js.map +1 -1
- package/dist/server/hooks/error-reporting.d.ts +8 -0
- package/dist/server/hooks/error-reporting.d.ts.map +1 -1
- package/dist/server/hooks/error-reporting.js +55 -25
- package/dist/server/hooks/error-reporting.js.map +1 -1
- package/dist/server/hooks/idempotency.d.ts +1 -1
- package/dist/server/hooks/idempotency.d.ts.map +1 -1
- package/dist/server/hooks/idempotency.js +30 -9
- package/dist/server/hooks/idempotency.js.map +1 -1
- package/dist/server/request-executor.d.ts.map +1 -1
- package/dist/server/request-executor.js +7 -12
- package/dist/server/request-executor.js.map +1 -1
- package/dist/server/request-preparation.d.ts.map +1 -1
- package/dist/server/request-preparation.js +3 -1
- package/dist/server/request-preparation.js.map +1 -1
- package/dist/server/response-finalization.d.ts +5 -1
- package/dist/server/response-finalization.d.ts.map +1 -1
- package/dist/server/response-finalization.js +66 -2
- package/dist/server/response-finalization.js.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +10 -1
- package/dist/server/server.js.map +1 -1
- package/dist/testing/index.d.ts +5 -5
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +9 -4
- package/dist/testing/index.js.map +1 -1
- package/dist/tracing/index.d.ts +27 -0
- package/dist/tracing/index.d.ts.map +1 -1
- package/dist/tracing/index.js +96 -0
- package/dist/tracing/index.js.map +1 -1
- package/dist/uploads/index.d.ts.map +1 -1
- package/dist/uploads/index.js +39 -6
- package/dist/uploads/index.js.map +1 -1
- package/dist/webhooks/index.d.ts +2 -1
- package/dist/webhooks/index.d.ts.map +1 -1
- package/dist/webhooks/index.js +25 -1
- package/dist/webhooks/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/app-architecture/SKILL.md +19 -1
- package/src/client/client.ts +6 -31
- package/src/contracts/contract-builder.ts +38 -0
- package/src/contracts/contract-group.ts +25 -0
- package/src/contracts/index.ts +8 -0
- package/src/contracts/lifecycle.ts +236 -0
- package/src/contracts/types.ts +5 -0
- package/src/error-reporting/index.ts +111 -0
- package/src/error-reporting/internal.ts +47 -0
- package/src/events/index.ts +20 -4
- package/src/idempotency/index.ts +81 -7
- package/src/jobs/index.ts +85 -6
- package/src/locks/index.ts +65 -16
- package/src/openapi/index.ts +22 -2
- package/src/outbox/index.ts +119 -27
- package/src/ports/events.ts +8 -1
- package/src/ports/index.ts +4 -4
- package/src/ports/unbound.ts +1 -1
- package/src/ports/unit-of-work.ts +10 -3
- package/src/query-codec.ts +130 -0
- package/src/server/hooks/cors.ts +10 -3
- package/src/server/hooks/error-reporting.ts +79 -23
- package/src/server/hooks/idempotency.ts +36 -10
- package/src/server/request-executor.ts +10 -13
- package/src/server/request-preparation.ts +4 -2
- package/src/server/response-finalization.ts +94 -5
- package/src/server/server.ts +15 -1
- package/src/testing/index.ts +25 -14
- package/src/tracing/index.ts +120 -0
- package/src/uploads/index.ts +44 -6
- package/src/webhooks/index.ts +34 -2
- package/dist/domain/events.d.ts +0 -44
- package/dist/domain/events.d.ts.map +0 -1
- package/dist/domain/events.js +0 -24
- package/dist/domain/events.js.map +0 -1
|
@@ -7,6 +7,14 @@ import type {
|
|
|
7
7
|
ErrorReporterPort,
|
|
8
8
|
ErrorReportOptions,
|
|
9
9
|
} from "../../error-reporting/index.js";
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_ERROR_REPORTING_TIMEOUT_MS,
|
|
12
|
+
tryReportException,
|
|
13
|
+
} from "../../error-reporting/index.js";
|
|
14
|
+
import {
|
|
15
|
+
errorReportingObserverTimeout,
|
|
16
|
+
runErrorReportingOperation,
|
|
17
|
+
} from "../../error-reporting/internal.js";
|
|
10
18
|
import { isAppError, SchemaValidationError } from "../../errors/index.js";
|
|
11
19
|
import {
|
|
12
20
|
IdempotencyConflictError,
|
|
@@ -81,6 +89,14 @@ export interface ErrorReportingHooksOptions<Ctx> {
|
|
|
81
89
|
reportOptions?:
|
|
82
90
|
| ErrorReportOptions
|
|
83
91
|
| ((args: ErrorReportingHookArgs<Ctx>) => MaybePromise<ErrorReportOptions>);
|
|
92
|
+
/**
|
|
93
|
+
* Maximum time allowed for reporting preparation, capture, and failure
|
|
94
|
+
* observation. Each phase is bounded independently. Set to `false` only for
|
|
95
|
+
* intentionally unbounded reporters.
|
|
96
|
+
*
|
|
97
|
+
* @default 1000
|
|
98
|
+
*/
|
|
99
|
+
timeoutMs?: number | false;
|
|
84
100
|
/**
|
|
85
101
|
* Observer for reporter failures. Reporter failures are otherwise ignored so
|
|
86
102
|
* error reporting cannot change the HTTP response.
|
|
@@ -107,42 +123,82 @@ export function createErrorReportingHooks<
|
|
|
107
123
|
return {
|
|
108
124
|
name: "error-reporting",
|
|
109
125
|
async onCaughtError(args) {
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
:
|
|
113
|
-
|
|
126
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_ERROR_REPORTING_TIMEOUT_MS;
|
|
127
|
+
let prepared:
|
|
128
|
+
| { reporter: ErrorReporterPort; reportOptions: ErrorReportOptions }
|
|
129
|
+
| undefined;
|
|
114
130
|
|
|
115
|
-
|
|
116
|
-
|
|
131
|
+
try {
|
|
132
|
+
prepared = await runErrorReportingOperation(async () => {
|
|
133
|
+
const shouldReport = config.shouldReport
|
|
134
|
+
? await config.shouldReport(args)
|
|
135
|
+
: shouldReportServerError(args.err);
|
|
136
|
+
if (!shouldReport) return undefined;
|
|
117
137
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
typeof config.reportOptions === "function"
|
|
121
|
-
? await config.reportOptions(args)
|
|
122
|
-
: config.reportOptions;
|
|
138
|
+
const reporter = await resolveReporter(config.reporter, args);
|
|
139
|
+
if (!reporter) return undefined;
|
|
123
140
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
141
|
+
const baseOptions = createDefaultReportOptions(args);
|
|
142
|
+
const customOptions =
|
|
143
|
+
typeof config.reportOptions === "function"
|
|
144
|
+
? await config.reportOptions(args)
|
|
145
|
+
: config.reportOptions;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
reporter,
|
|
149
|
+
reportOptions: mergeReportOptions(baseOptions, customOptions),
|
|
150
|
+
};
|
|
151
|
+
}, timeoutMs);
|
|
129
152
|
} catch (reportingError) {
|
|
130
|
-
|
|
131
|
-
|
|
153
|
+
await notifyReporterError(config, args, reportingError, timeoutMs);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!prepared) return;
|
|
158
|
+
|
|
159
|
+
await tryReportException({
|
|
160
|
+
reporter: prepared.reporter,
|
|
161
|
+
error: args.err,
|
|
162
|
+
reportOptions: prepared.reportOptions,
|
|
163
|
+
timeoutMs,
|
|
164
|
+
onReporterError: ({ reportingError }) =>
|
|
165
|
+
config.onReporterError?.({
|
|
132
166
|
error: args.err,
|
|
133
167
|
reportingError,
|
|
134
168
|
ctx: args.ctx,
|
|
135
169
|
req: args.req,
|
|
136
170
|
contract: args.contract,
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
// Reporter failure observers must not affect request handling.
|
|
140
|
-
}
|
|
141
|
-
}
|
|
171
|
+
}),
|
|
172
|
+
});
|
|
142
173
|
},
|
|
143
174
|
};
|
|
144
175
|
}
|
|
145
176
|
|
|
177
|
+
async function notifyReporterError<Ctx>(
|
|
178
|
+
config: ErrorReportingHooksOptions<Ctx>,
|
|
179
|
+
args: ErrorReportingHookArgs<Ctx>,
|
|
180
|
+
reportingError: unknown,
|
|
181
|
+
timeoutMs: number | false,
|
|
182
|
+
): Promise<void> {
|
|
183
|
+
if (!config.onReporterError) return;
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
await runErrorReportingOperation(
|
|
187
|
+
() =>
|
|
188
|
+
config.onReporterError?.({
|
|
189
|
+
error: args.err,
|
|
190
|
+
reportingError,
|
|
191
|
+
ctx: args.ctx,
|
|
192
|
+
req: args.req,
|
|
193
|
+
contract: args.contract,
|
|
194
|
+
}),
|
|
195
|
+
errorReportingObserverTimeout(timeoutMs),
|
|
196
|
+
);
|
|
197
|
+
} catch {
|
|
198
|
+
// Reporting observers must not affect request handling.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
146
202
|
/**
|
|
147
203
|
* Default filter for HTTP error reporting.
|
|
148
204
|
*/
|
|
@@ -11,11 +11,17 @@ import {
|
|
|
11
11
|
type IdempotencyPort,
|
|
12
12
|
type IdempotencyScope,
|
|
13
13
|
} from "../../idempotency/index.js";
|
|
14
|
-
import
|
|
14
|
+
import {
|
|
15
|
+
type ActivityActor,
|
|
16
|
+
type ActivityTenant,
|
|
17
|
+
AuthUnauthorizedError,
|
|
18
|
+
TenantRequiredError,
|
|
19
|
+
} from "../../ports/index.js";
|
|
15
20
|
import {
|
|
16
21
|
type ResponseFinalizerServerHook,
|
|
17
22
|
responseFinalizerHook,
|
|
18
23
|
} from "../internal-hooks.js";
|
|
24
|
+
import { finalizeResponse } from "../response-finalization.js";
|
|
19
25
|
import type {
|
|
20
26
|
HttpRequestLike,
|
|
21
27
|
HttpResponseLike,
|
|
@@ -84,6 +90,7 @@ type PendingReservation = {
|
|
|
84
90
|
key: string;
|
|
85
91
|
scope: IdempotencyScope;
|
|
86
92
|
fingerprint: string;
|
|
93
|
+
reservationToken: string;
|
|
87
94
|
};
|
|
88
95
|
|
|
89
96
|
function defaultIdempotencyScope(
|
|
@@ -96,11 +103,15 @@ function defaultIdempotencyScope(
|
|
|
96
103
|
case "global":
|
|
97
104
|
return "global";
|
|
98
105
|
case "actor":
|
|
99
|
-
|
|
106
|
+
if (!ctx.actor?.id) throw new AuthUnauthorizedError();
|
|
107
|
+
return { actorId: ctx.actor.id };
|
|
100
108
|
case "tenant":
|
|
101
|
-
|
|
109
|
+
if (!ctx.tenant?.id) throw new TenantRequiredError();
|
|
110
|
+
return { tenantId: ctx.tenant.id };
|
|
102
111
|
case "actor-tenant":
|
|
103
|
-
|
|
112
|
+
if (!ctx.actor?.id) throw new AuthUnauthorizedError();
|
|
113
|
+
if (!ctx.tenant?.id) throw new TenantRequiredError();
|
|
114
|
+
return { actorId: ctx.actor.id, tenantId: ctx.tenant.id };
|
|
104
115
|
}
|
|
105
116
|
}
|
|
106
117
|
|
|
@@ -193,6 +204,7 @@ export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
|
|
|
193
204
|
scope,
|
|
194
205
|
fingerprint,
|
|
195
206
|
ttlSec: meta.ttlSec,
|
|
207
|
+
reservationTtlSec: meta.reservationTtlSec,
|
|
196
208
|
});
|
|
197
209
|
|
|
198
210
|
switch (reservation.status) {
|
|
@@ -205,13 +217,17 @@ export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
|
|
|
205
217
|
);
|
|
206
218
|
}
|
|
207
219
|
|
|
220
|
+
const validated = await finalizeResponse(
|
|
221
|
+
contract,
|
|
222
|
+
reservation.result,
|
|
223
|
+
);
|
|
208
224
|
return {
|
|
209
|
-
status:
|
|
225
|
+
status: validated.status,
|
|
210
226
|
headers: {
|
|
211
|
-
...(
|
|
227
|
+
...(validated.headers ?? {}),
|
|
212
228
|
[IDEMPOTENCY_REPLAYED_HEADER]: "true",
|
|
213
229
|
},
|
|
214
|
-
body:
|
|
230
|
+
body: validated.body,
|
|
215
231
|
};
|
|
216
232
|
}
|
|
217
233
|
case "inProgress": {
|
|
@@ -227,6 +243,7 @@ export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
|
|
|
227
243
|
key,
|
|
228
244
|
scope,
|
|
229
245
|
fingerprint,
|
|
246
|
+
reservationToken: reservation.reservationToken,
|
|
230
247
|
});
|
|
231
248
|
return undefined;
|
|
232
249
|
}
|
|
@@ -246,11 +263,12 @@ export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
|
|
|
246
263
|
}
|
|
247
264
|
pending.delete(req);
|
|
248
265
|
|
|
249
|
-
const { port, namespace, key, scope, fingerprint } =
|
|
266
|
+
const { port, namespace, key, scope, fingerprint, reservationToken } =
|
|
267
|
+
reservation;
|
|
250
268
|
|
|
251
269
|
if (
|
|
252
270
|
owner === "route" &&
|
|
253
|
-
responseValidation
|
|
271
|
+
responseValidation === "validated" &&
|
|
254
272
|
!native &&
|
|
255
273
|
!error &&
|
|
256
274
|
response.status >= 200 &&
|
|
@@ -261,6 +279,7 @@ export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
|
|
|
261
279
|
key,
|
|
262
280
|
scope,
|
|
263
281
|
fingerprint,
|
|
282
|
+
reservationToken,
|
|
264
283
|
result: {
|
|
265
284
|
status: response.status,
|
|
266
285
|
headers: response.headers,
|
|
@@ -272,7 +291,14 @@ export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
|
|
|
272
291
|
|
|
273
292
|
// Framework-owned responses, errors, non-2xx responses, and native
|
|
274
293
|
// `Response` results release the reservation. Streams are not replayable.
|
|
275
|
-
await port.fail({
|
|
294
|
+
await port.fail({
|
|
295
|
+
namespace,
|
|
296
|
+
key,
|
|
297
|
+
scope,
|
|
298
|
+
fingerprint,
|
|
299
|
+
reservationToken,
|
|
300
|
+
error,
|
|
301
|
+
});
|
|
276
302
|
return undefined;
|
|
277
303
|
},
|
|
278
304
|
};
|
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
responseForHooks,
|
|
73
73
|
responseOwnerFor,
|
|
74
74
|
toContractViolationResponse,
|
|
75
|
+
withContractLifecycleHeaders,
|
|
75
76
|
withFrameworkErrorOwnerHeader,
|
|
76
77
|
} from "./response-finalization.js";
|
|
77
78
|
import {
|
|
@@ -888,28 +889,20 @@ export function createRequestExecutor<
|
|
|
888
889
|
let finalOwner = responseOwnerFor(finalResponse, result.owner);
|
|
889
890
|
let responseValidation: ResponseFinalizerValidationState =
|
|
890
891
|
"not-applicable";
|
|
891
|
-
if (
|
|
892
|
-
|
|
893
|
-
!isWebResponse(finalResponse) &&
|
|
894
|
-
!(options.validateResponses ?? true)
|
|
895
|
-
) {
|
|
896
|
-
responseValidation = "disabled";
|
|
897
|
-
} else if (
|
|
898
|
-
finalOwner === "route" &&
|
|
899
|
-
!isWebResponse(finalResponse) &&
|
|
900
|
-
(options.validateResponses ?? true)
|
|
901
|
-
) {
|
|
892
|
+
if (finalOwner === "route" && !isWebResponse(finalResponse)) {
|
|
893
|
+
const validateContract = options.validateResponses ?? true;
|
|
902
894
|
try {
|
|
903
895
|
finalResponse = await finalizeResponse(
|
|
904
896
|
contract,
|
|
905
897
|
finalResponse,
|
|
906
898
|
target.responseValidationExemptStatus,
|
|
899
|
+
{ validateContract },
|
|
907
900
|
);
|
|
908
901
|
result = {
|
|
909
902
|
...result,
|
|
910
903
|
response: finalResponse,
|
|
911
904
|
};
|
|
912
|
-
responseValidation = "validated";
|
|
905
|
+
responseValidation = validateContract ? "validated" : "disabled";
|
|
913
906
|
} catch (error) {
|
|
914
907
|
if (error instanceof ResponseContractViolationError) {
|
|
915
908
|
result = {
|
|
@@ -947,6 +940,7 @@ export function createRequestExecutor<
|
|
|
947
940
|
finalOwner,
|
|
948
941
|
);
|
|
949
942
|
}
|
|
943
|
+
finalResponse = withContractLifecycleHeaders(finalResponse, contract);
|
|
950
944
|
finalResponse = withoutHeadResponseBody(finalResponse, req.method);
|
|
951
945
|
stages.sendMs = performance.now() - sendStartedAt;
|
|
952
946
|
|
|
@@ -987,7 +981,10 @@ export function createRequestExecutor<
|
|
|
987
981
|
},
|
|
988
982
|
);
|
|
989
983
|
const response = withoutHeadResponseBody(
|
|
990
|
-
|
|
984
|
+
withContractLifecycleHeaders(
|
|
985
|
+
normalizeHttpResponse(result.response),
|
|
986
|
+
contract,
|
|
987
|
+
),
|
|
991
988
|
req.method,
|
|
992
989
|
);
|
|
993
990
|
if (isWebResponse(response)) {
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
methodSupportsRequestBody,
|
|
5
5
|
type StandardSchema,
|
|
6
6
|
} from "../contracts/index.js";
|
|
7
|
+
import { decodeQueryValue } from "../query-codec.js";
|
|
7
8
|
import type { HttpRequestLike, HttpResponseLike } from "./http.js";
|
|
8
9
|
import {
|
|
9
10
|
parseStandardSchema,
|
|
@@ -272,10 +273,11 @@ export async function prepareRequestInputs(args: {
|
|
|
272
273
|
maxRequestBodyBytes,
|
|
273
274
|
rawRoute,
|
|
274
275
|
} = args;
|
|
275
|
-
const rawQuery: Record<string,
|
|
276
|
+
const rawQuery: Record<string, unknown> = {};
|
|
276
277
|
for (const key of new Set(url.searchParams.keys())) {
|
|
277
278
|
const values = url.searchParams.getAll(key);
|
|
278
|
-
|
|
279
|
+
const decoded = values.map(decodeQueryValue);
|
|
280
|
+
rawQuery[key] = decoded.length === 1 ? decoded[0] : decoded;
|
|
279
281
|
}
|
|
280
282
|
|
|
281
283
|
let query: unknown = rawQuery;
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
type ContractErrorDefinition,
|
|
4
4
|
type HttpContractConfig,
|
|
5
5
|
} from "../contracts/index.js";
|
|
6
|
+
import { contractLifecycleResponseHeaders } from "../contracts/lifecycle.js";
|
|
6
7
|
import {
|
|
7
8
|
createErrorResponseBody,
|
|
8
9
|
isErrorResponseBody,
|
|
@@ -74,6 +75,63 @@ export function withFrameworkErrorOwnerHeader(
|
|
|
74
75
|
};
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
function setRecordHeader(
|
|
79
|
+
headers: Record<string, string>,
|
|
80
|
+
name: string,
|
|
81
|
+
value: string,
|
|
82
|
+
): void {
|
|
83
|
+
const existingName = Object.keys(headers).find(
|
|
84
|
+
(key) => key.toLowerCase() === name.toLowerCase(),
|
|
85
|
+
);
|
|
86
|
+
if (existingName && existingName !== name) {
|
|
87
|
+
delete headers[existingName];
|
|
88
|
+
}
|
|
89
|
+
headers[name] = value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Apply contract-owned deprecation headers to any response representation. */
|
|
93
|
+
export function withContractLifecycleHeaders(
|
|
94
|
+
res: HttpResponse,
|
|
95
|
+
contract: HttpContractConfig,
|
|
96
|
+
): HttpResponse {
|
|
97
|
+
const lifecycleHeaders = contractLifecycleResponseHeaders(contract);
|
|
98
|
+
if (Object.keys(lifecycleHeaders).length === 0) return res;
|
|
99
|
+
|
|
100
|
+
if (isWebResponse(res)) {
|
|
101
|
+
const headers = new Headers(res.headers);
|
|
102
|
+
for (const [name, value] of Object.entries(lifecycleHeaders)) {
|
|
103
|
+
if (name.toLowerCase() === "link" && headers.has(name)) {
|
|
104
|
+
headers.append(name, value);
|
|
105
|
+
} else {
|
|
106
|
+
headers.set(name, value);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return new Response(res.body, {
|
|
110
|
+
status: res.status,
|
|
111
|
+
statusText: res.statusText,
|
|
112
|
+
headers,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const headers = { ...(res.headers ?? {}) };
|
|
117
|
+
for (const [name, value] of Object.entries(lifecycleHeaders)) {
|
|
118
|
+
if (name.toLowerCase() === "link") {
|
|
119
|
+
const existingName = Object.keys(headers).find(
|
|
120
|
+
(key) => key.toLowerCase() === "link",
|
|
121
|
+
);
|
|
122
|
+
const existing = existingName ? headers[existingName] : undefined;
|
|
123
|
+
setRecordHeader(
|
|
124
|
+
headers,
|
|
125
|
+
name,
|
|
126
|
+
existing ? `${existing}, ${value}` : value,
|
|
127
|
+
);
|
|
128
|
+
} else {
|
|
129
|
+
setRecordHeader(headers, name, value);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { ...res, headers };
|
|
133
|
+
}
|
|
134
|
+
|
|
77
135
|
export function responseOwnerFor(
|
|
78
136
|
res: HttpResponse,
|
|
79
137
|
owner?: ResponseOwner,
|
|
@@ -333,17 +391,48 @@ async function validateResponseAgainstContract<C extends HttpContractConfig>(
|
|
|
333
391
|
}
|
|
334
392
|
}
|
|
335
393
|
|
|
394
|
+
const BODYLESS_RESPONSE_STATUSES = new Set([204, 205, 304]);
|
|
395
|
+
|
|
396
|
+
function validateHttpResponseSemantics(
|
|
397
|
+
contract: HttpContractConfig,
|
|
398
|
+
res: HttpResponseLike,
|
|
399
|
+
): void {
|
|
400
|
+
if (
|
|
401
|
+
!BODYLESS_RESPONSE_STATUSES.has(res.status) ||
|
|
402
|
+
res.body === undefined ||
|
|
403
|
+
res.body === null
|
|
404
|
+
) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
throw new ResponseContractViolationError({
|
|
409
|
+
code: "RESPONSE_VALIDATION_ERROR",
|
|
410
|
+
message: responseContractViolationMessage(contract, res.status),
|
|
411
|
+
details: responseContractViolationDetails(contract, res.status, {
|
|
412
|
+
issues: [
|
|
413
|
+
{
|
|
414
|
+
message: `HTTP status ${res.status} must not include a response body.`,
|
|
415
|
+
},
|
|
416
|
+
],
|
|
417
|
+
}),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
336
421
|
export async function finalizeResponse<C extends HttpContractConfig>(
|
|
337
422
|
contract: C,
|
|
338
423
|
res: HttpResponseLike,
|
|
339
424
|
responseValidationExemptStatus?: number,
|
|
425
|
+
options: { validateContract?: boolean } = {},
|
|
340
426
|
): Promise<HttpResponseLike> {
|
|
341
427
|
const normalized = normalizeResponse(res);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
428
|
+
validateHttpResponseSemantics(contract, normalized);
|
|
429
|
+
if (options.validateContract ?? true) {
|
|
430
|
+
await validateResponseAgainstContract(
|
|
431
|
+
contract,
|
|
432
|
+
normalized,
|
|
433
|
+
responseValidationExemptStatus,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
347
436
|
return normalized;
|
|
348
437
|
}
|
|
349
438
|
|
package/src/server/server.ts
CHANGED
|
@@ -3,6 +3,10 @@ import {
|
|
|
3
3
|
type HttpContractConfig,
|
|
4
4
|
methodSupportsRequestBody,
|
|
5
5
|
} from "../contracts/index.js";
|
|
6
|
+
import {
|
|
7
|
+
assertValidContractLifecycle,
|
|
8
|
+
getContractOperationId,
|
|
9
|
+
} from "../contracts/lifecycle.js";
|
|
6
10
|
import {
|
|
7
11
|
comparePathParamsToTemplate,
|
|
8
12
|
formatPathParamsMismatch,
|
|
@@ -539,6 +543,7 @@ export async function createServer<
|
|
|
539
543
|
const registeredPaths = new Set<string>();
|
|
540
544
|
const registeredShapes = new Map<string, string>();
|
|
541
545
|
const registeredNames = new Map<string, string>();
|
|
546
|
+
const registeredOperationIds = new Map<string, string>();
|
|
542
547
|
|
|
543
548
|
const registerRoute = <C extends HttpContractConfig>(
|
|
544
549
|
contract: C,
|
|
@@ -546,6 +551,7 @@ export async function createServer<
|
|
|
546
551
|
routeHooks: readonly RouteHook<unknown, object>[] = [],
|
|
547
552
|
responseValidationExemptStatus?: number,
|
|
548
553
|
): void => {
|
|
554
|
+
assertValidContractLifecycle(contract);
|
|
549
555
|
if (contract.body && !methodSupportsRequestBody(contract.method)) {
|
|
550
556
|
throw new Error(
|
|
551
557
|
`Request bodies are not supported for ${contract.method} contracts. Use POST, PUT, or PATCH for contract request bodies.`,
|
|
@@ -572,6 +578,13 @@ export async function createServer<
|
|
|
572
578
|
`Duplicate contract name: "${contract.name}" is registered for both ${conflictingName} and ${routeKey}. Contract names must be unique because typed clients, OpenAPI operations, and devtools key on them.`,
|
|
573
579
|
);
|
|
574
580
|
}
|
|
581
|
+
const operationId = getContractOperationId(contract);
|
|
582
|
+
const conflictingOperationId = registeredOperationIds.get(operationId);
|
|
583
|
+
if (conflictingOperationId) {
|
|
584
|
+
throw new Error(
|
|
585
|
+
`Duplicate OpenAPI operationId: "${operationId}" is registered for both ${conflictingOperationId} and ${routeKey}. Operation IDs must be unique across the registered route surface.`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
575
588
|
if (contract.pathParams) {
|
|
576
589
|
const shape = getObjectSchemaShape(contract.pathParams);
|
|
577
590
|
if (shape) {
|
|
@@ -590,6 +603,7 @@ export async function createServer<
|
|
|
590
603
|
registeredPaths.add(routeKey);
|
|
591
604
|
registeredShapes.set(shapeRouteKey, routeKey);
|
|
592
605
|
registeredNames.set(contract.name, routeKey);
|
|
606
|
+
registeredOperationIds.set(operationId, routeKey);
|
|
593
607
|
|
|
594
608
|
const builtHandler = buildHandler(
|
|
595
609
|
options,
|
|
@@ -720,7 +734,7 @@ export async function createServer<
|
|
|
720
734
|
const message =
|
|
721
735
|
`Unbound ports after provider startup: ${unboundKeys.join(", ")}. ` +
|
|
722
736
|
"Each port declared as deferred in definePorts(...) must be contributed " +
|
|
723
|
-
"by a provider (server/providers.ts) or bound in infra/
|
|
737
|
+
"by a provider (server/providers.ts) or bound in infra/port-wiring.ts. " +
|
|
724
738
|
'Pass onUnboundPorts: "warn" or "ignore" to change this behavior.';
|
|
725
739
|
if (onUnboundPorts === "error") {
|
|
726
740
|
throw new Error(message);
|
package/src/testing/index.ts
CHANGED
|
@@ -13,7 +13,11 @@ import {
|
|
|
13
13
|
createMemoryNotificationPort,
|
|
14
14
|
type MemoryNotificationPort,
|
|
15
15
|
} from "../notifications/index.js";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createMemoryOutbox,
|
|
18
|
+
enqueueEvent,
|
|
19
|
+
type MemoryOutboxPort,
|
|
20
|
+
} from "../outbox/index.js";
|
|
17
21
|
import {
|
|
18
22
|
createMemoryPayments,
|
|
19
23
|
type MemoryPaymentsPort,
|
|
@@ -289,11 +293,11 @@ export interface CreateTestPortsTransactionOptions<
|
|
|
289
293
|
*/
|
|
290
294
|
afterRollback?: (error: unknown, tx: TxPorts) => MaybePromise<void>;
|
|
291
295
|
/**
|
|
292
|
-
*
|
|
296
|
+
* Enqueue `tx.events` (a buffered domain event recorder) to `ports.outbox`
|
|
293
297
|
* after the transaction commits, and clear it after a rollback.
|
|
294
298
|
*
|
|
295
|
-
* Requires `transaction.ports` to include an `events` recorder
|
|
296
|
-
* `createDomainEventRecorder()
|
|
299
|
+
* Requires `transaction.ports` to include an `events` recorder created by
|
|
300
|
+
* `createDomainEventRecorder()`.
|
|
297
301
|
*/
|
|
298
302
|
outbox?: boolean;
|
|
299
303
|
}
|
|
@@ -328,7 +332,7 @@ export interface CreateTestPortsOptions<
|
|
|
328
332
|
TxPorts = Ports,
|
|
329
333
|
> {
|
|
330
334
|
/**
|
|
331
|
-
* App-owned default ports, usually imported from `infra/
|
|
335
|
+
* App-owned default ports, usually imported from `infra/port-wiring`. Common
|
|
332
336
|
* Beignet test defaults replace matching keys from `base`; use `overrides`
|
|
333
337
|
* for app ports that should win.
|
|
334
338
|
*
|
|
@@ -452,9 +456,16 @@ export function createTestPorts<
|
|
|
452
456
|
{
|
|
453
457
|
afterCommit: async (tx) => {
|
|
454
458
|
if (flushEventsToOutbox) {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
459
|
+
const events = resolveBufferedTransactionEvents(tx);
|
|
460
|
+
const outbox = (
|
|
461
|
+
portsWithoutUow as unknown as {
|
|
462
|
+
outbox: MemoryOutboxPort;
|
|
463
|
+
}
|
|
464
|
+
).outbox;
|
|
465
|
+
for (const entry of events.entries()) {
|
|
466
|
+
await enqueueEvent(outbox, entry.event, entry.payload);
|
|
467
|
+
}
|
|
468
|
+
events.clear();
|
|
458
469
|
}
|
|
459
470
|
await transactionOptions?.afterCommit?.(tx);
|
|
460
471
|
},
|
|
@@ -578,26 +589,26 @@ function completeTestPortOverrides<Ports extends AnyPorts>(
|
|
|
578
589
|
|
|
579
590
|
function resolveBufferedTransactionEvents(
|
|
580
591
|
tx: unknown,
|
|
581
|
-
): Pick<BufferedDomainEventRecorder, "
|
|
592
|
+
): Pick<BufferedDomainEventRecorder, "entries" | "clear"> {
|
|
582
593
|
const events =
|
|
583
594
|
tx && typeof tx === "object"
|
|
584
595
|
? (tx as { events?: unknown }).events
|
|
585
596
|
: undefined;
|
|
586
597
|
const recorder = events as
|
|
587
|
-
| Partial<Pick<BufferedDomainEventRecorder, "
|
|
598
|
+
| Partial<Pick<BufferedDomainEventRecorder, "entries" | "clear">>
|
|
588
599
|
| undefined;
|
|
589
600
|
if (
|
|
590
601
|
!recorder ||
|
|
591
|
-
typeof recorder.
|
|
602
|
+
typeof recorder.entries !== "function" ||
|
|
592
603
|
typeof recorder.clear !== "function"
|
|
593
604
|
) {
|
|
594
605
|
throw new Error(
|
|
595
606
|
"createTestPorts transaction.outbox requires tx.events to be a buffered domain event recorder. " +
|
|
596
|
-
"Add events: createDomainEventRecorder()
|
|
607
|
+
"Add events: createDomainEventRecorder() to transaction.ports.",
|
|
597
608
|
);
|
|
598
609
|
}
|
|
599
610
|
|
|
600
|
-
return recorder as Pick<BufferedDomainEventRecorder, "
|
|
611
|
+
return recorder as Pick<BufferedDomainEventRecorder, "entries" | "clear">;
|
|
601
612
|
}
|
|
602
613
|
|
|
603
614
|
/**
|
|
@@ -831,7 +842,7 @@ export interface CreateTestContextOptions<Ctx, Ports extends AnyPorts> {
|
|
|
831
842
|
*/
|
|
832
843
|
auth?: unknown;
|
|
833
844
|
/**
|
|
834
|
-
* App-owned default ports, usually imported from `infra/
|
|
845
|
+
* App-owned default ports, usually imported from `infra/port-wiring`. Common
|
|
835
846
|
* Beignet test defaults replace matching keys from `base`; use `ports` for
|
|
836
847
|
* app ports that should win.
|
|
837
848
|
*/
|