@beignet/core 0.0.37 → 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 +13 -0
- package/README.md +108 -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/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/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 +4 -10
- 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 +3 -1
- package/dist/server/response-finalization.d.ts.map +1 -1
- package/dist/server/response-finalization.js +24 -2
- package/dist/server/response-finalization.js.map +1 -1
- package/dist/server/server.js +1 -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/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/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 +4 -12
- package/src/server/request-preparation.ts +4 -2
- package/src/server/response-finalization.ts +36 -5
- package/src/server/server.ts +1 -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/src/server/hooks/cors.ts
CHANGED
|
@@ -104,8 +104,9 @@ export function applyCorsHeaders(
|
|
|
104
104
|
/**
|
|
105
105
|
* Create CORS hooks for preflight and regular responses.
|
|
106
106
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
107
|
+
* CORS preflight requests short-circuit with a 204 response. Explicit
|
|
108
|
+
* `OPTIONS` routes without `Access-Control-Request-Method` continue through the
|
|
109
|
+
* normal route pipeline. All responses are decorated in `beforeSend`.
|
|
109
110
|
*/
|
|
110
111
|
export function createCorsHooks<Ctx>(config: CorsConfig): ServerHook<Ctx> {
|
|
111
112
|
const corsConfig = resolveCorsConfig(config);
|
|
@@ -113,7 +114,13 @@ export function createCorsHooks<Ctx>(config: CorsConfig): ServerHook<Ctx> {
|
|
|
113
114
|
return {
|
|
114
115
|
name: "cors",
|
|
115
116
|
onRequest: ({ req }) => {
|
|
116
|
-
if (
|
|
117
|
+
if (
|
|
118
|
+
req.method !== "OPTIONS" ||
|
|
119
|
+
!req.headers.get("Origin") ||
|
|
120
|
+
!req.headers.get("Access-Control-Request-Method")
|
|
121
|
+
) {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
117
124
|
const headers: Record<string, string> = {};
|
|
118
125
|
applyCorsHeaders(headers, req, corsConfig);
|
|
119
126
|
return {
|
|
@@ -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
|
};
|
|
@@ -889,28 +889,20 @@ export function createRequestExecutor<
|
|
|
889
889
|
let finalOwner = responseOwnerFor(finalResponse, result.owner);
|
|
890
890
|
let responseValidation: ResponseFinalizerValidationState =
|
|
891
891
|
"not-applicable";
|
|
892
|
-
if (
|
|
893
|
-
|
|
894
|
-
!isWebResponse(finalResponse) &&
|
|
895
|
-
!(options.validateResponses ?? true)
|
|
896
|
-
) {
|
|
897
|
-
responseValidation = "disabled";
|
|
898
|
-
} else if (
|
|
899
|
-
finalOwner === "route" &&
|
|
900
|
-
!isWebResponse(finalResponse) &&
|
|
901
|
-
(options.validateResponses ?? true)
|
|
902
|
-
) {
|
|
892
|
+
if (finalOwner === "route" && !isWebResponse(finalResponse)) {
|
|
893
|
+
const validateContract = options.validateResponses ?? true;
|
|
903
894
|
try {
|
|
904
895
|
finalResponse = await finalizeResponse(
|
|
905
896
|
contract,
|
|
906
897
|
finalResponse,
|
|
907
898
|
target.responseValidationExemptStatus,
|
|
899
|
+
{ validateContract },
|
|
908
900
|
);
|
|
909
901
|
result = {
|
|
910
902
|
...result,
|
|
911
903
|
response: finalResponse,
|
|
912
904
|
};
|
|
913
|
-
responseValidation = "validated";
|
|
905
|
+
responseValidation = validateContract ? "validated" : "disabled";
|
|
914
906
|
} catch (error) {
|
|
915
907
|
if (error instanceof ResponseContractViolationError) {
|
|
916
908
|
result = {
|
|
@@ -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;
|
|
@@ -391,17 +391,48 @@ async function validateResponseAgainstContract<C extends HttpContractConfig>(
|
|
|
391
391
|
}
|
|
392
392
|
}
|
|
393
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
|
+
|
|
394
421
|
export async function finalizeResponse<C extends HttpContractConfig>(
|
|
395
422
|
contract: C,
|
|
396
423
|
res: HttpResponseLike,
|
|
397
424
|
responseValidationExemptStatus?: number,
|
|
425
|
+
options: { validateContract?: boolean } = {},
|
|
398
426
|
): Promise<HttpResponseLike> {
|
|
399
427
|
const normalized = normalizeResponse(res);
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
428
|
+
validateHttpResponseSemantics(contract, normalized);
|
|
429
|
+
if (options.validateContract ?? true) {
|
|
430
|
+
await validateResponseAgainstContract(
|
|
431
|
+
contract,
|
|
432
|
+
normalized,
|
|
433
|
+
responseValidationExemptStatus,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
405
436
|
return normalized;
|
|
406
437
|
}
|
|
407
438
|
|
package/src/server/server.ts
CHANGED
|
@@ -734,7 +734,7 @@ export async function createServer<
|
|
|
734
734
|
const message =
|
|
735
735
|
`Unbound ports after provider startup: ${unboundKeys.join(", ")}. ` +
|
|
736
736
|
"Each port declared as deferred in definePorts(...) must be contributed " +
|
|
737
|
-
"by a provider (server/providers.ts) or bound in infra/
|
|
737
|
+
"by a provider (server/providers.ts) or bound in infra/port-wiring.ts. " +
|
|
738
738
|
'Pass onUnboundPorts: "warn" or "ignore" to change this behavior.';
|
|
739
739
|
if (onUnboundPorts === "error") {
|
|
740
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
|
*/
|
package/src/tracing/index.ts
CHANGED
|
@@ -9,6 +9,29 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const TRACEPARENT_PATTERN = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
12
|
+
const MAX_TRACESTATE_LENGTH = 512;
|
|
13
|
+
const MAX_TRACESTATE_MEMBERS = 32;
|
|
14
|
+
const TRACESTATE_SIMPLE_KEY_PATTERN = /^[a-z][a-z0-9_*/-]{0,255}$/;
|
|
15
|
+
const TRACESTATE_VENDOR_KEY_PATTERN =
|
|
16
|
+
/^[a-z0-9][a-z0-9_*/-]{0,240}@[a-z][a-z0-9_*/-]{0,13}$/;
|
|
17
|
+
const TRACESTATE_VALUE_PATTERN =
|
|
18
|
+
/^[\x20-\x2b\x2d-\x3c\x3e-\x7e]{0,255}[\x21-\x2b\x2d-\x3c\x3e-\x7e]$/;
|
|
19
|
+
|
|
20
|
+
/** Current version of Beignet's durable trace carrier. */
|
|
21
|
+
export const TRACE_CARRIER_VERSION = 1 as const;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Vendor-neutral trace context stored in durable messages and transport
|
|
25
|
+
* envelopes. Unknown versions and malformed values are ignored by consumers.
|
|
26
|
+
*/
|
|
27
|
+
export interface TraceCarrier {
|
|
28
|
+
/** Carrier schema version. */
|
|
29
|
+
readonly version: typeof TRACE_CARRIER_VERSION;
|
|
30
|
+
/** W3C traceparent value captured at the producing boundary. */
|
|
31
|
+
readonly traceparent: string;
|
|
32
|
+
/** Optional W3C tracestate value captured at the producing boundary. */
|
|
33
|
+
readonly tracestate?: string;
|
|
34
|
+
}
|
|
12
35
|
|
|
13
36
|
/**
|
|
14
37
|
* Trace context used to correlate related activity.
|
|
@@ -161,6 +184,103 @@ export function resolveTracingPort(target: unknown): TracingPort | undefined {
|
|
|
161
184
|
return resolveTracingPort(ports);
|
|
162
185
|
}
|
|
163
186
|
|
|
187
|
+
function validTracestate(value: unknown): string | undefined {
|
|
188
|
+
if (typeof value !== "string") return undefined;
|
|
189
|
+
const normalized = value.trim();
|
|
190
|
+
if (normalized.length === 0 || normalized.length > MAX_TRACESTATE_LENGTH) {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const members = normalized.split(",");
|
|
195
|
+
if (members.length > MAX_TRACESTATE_MEMBERS) return undefined;
|
|
196
|
+
|
|
197
|
+
const keys = new Set<string>();
|
|
198
|
+
for (const rawMember of members) {
|
|
199
|
+
const member = rawMember.trim();
|
|
200
|
+
const separator = member.indexOf("=");
|
|
201
|
+
if (separator <= 0) return undefined;
|
|
202
|
+
|
|
203
|
+
const key = member.slice(0, separator).trim();
|
|
204
|
+
const memberValue = member.slice(separator + 1).trimStart();
|
|
205
|
+
if (
|
|
206
|
+
(!TRACESTATE_SIMPLE_KEY_PATTERN.test(key) &&
|
|
207
|
+
!TRACESTATE_VENDOR_KEY_PATTERN.test(key)) ||
|
|
208
|
+
!TRACESTATE_VALUE_PATTERN.test(memberValue) ||
|
|
209
|
+
keys.has(key)
|
|
210
|
+
) {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
keys.add(key);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return normalized;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Parse an untrusted durable trace carrier.
|
|
221
|
+
*
|
|
222
|
+
* Invalid carriers return `undefined`; trace metadata must never prevent
|
|
223
|
+
* message delivery.
|
|
224
|
+
*/
|
|
225
|
+
export function parseTraceCarrier(value: unknown): TraceCarrier | undefined {
|
|
226
|
+
if (!isObject(value) || value.version !== TRACE_CARRIER_VERSION) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const parsed = parseTraceparent(
|
|
231
|
+
typeof value.traceparent === "string" ? value.traceparent : undefined,
|
|
232
|
+
);
|
|
233
|
+
if (!parsed) return undefined;
|
|
234
|
+
|
|
235
|
+
if (value.tracestate !== undefined) {
|
|
236
|
+
const tracestate = validTracestate(value.tracestate);
|
|
237
|
+
if (!tracestate) return undefined;
|
|
238
|
+
return {
|
|
239
|
+
version: TRACE_CARRIER_VERSION,
|
|
240
|
+
traceparent: parsed.traceparent,
|
|
241
|
+
tracestate,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
version: TRACE_CARRIER_VERSION,
|
|
247
|
+
traceparent: parsed.traceparent,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Capture the current trace context from a tracing port, ports object, app
|
|
253
|
+
* context, or explicit trace context. Returns `undefined` when no valid
|
|
254
|
+
* context is available.
|
|
255
|
+
*/
|
|
256
|
+
export function captureTraceCarrier(target: unknown): TraceCarrier | undefined {
|
|
257
|
+
let context: TraceContextInput | undefined;
|
|
258
|
+
try {
|
|
259
|
+
context = resolveTraceContextInput(target);
|
|
260
|
+
} catch {
|
|
261
|
+
// Context-like inputs may be proxy-backed and reject unknown properties.
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
context = resolveTracingPort(target)?.current() ?? context;
|
|
266
|
+
} catch {
|
|
267
|
+
// Tracing is best-effort and must not block the owning operation.
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
if (!context?.traceparent) return undefined;
|
|
272
|
+
|
|
273
|
+
return parseTraceCarrier({
|
|
274
|
+
version: TRACE_CARRIER_VERSION,
|
|
275
|
+
traceparent: context.traceparent,
|
|
276
|
+
...(context.tracestate ? { tracestate: context.tracestate } : {}),
|
|
277
|
+
});
|
|
278
|
+
} catch {
|
|
279
|
+
// Trace contexts are app-provided and remain best-effort inputs.
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
164
284
|
/** Resolve trace fields from a trace context or context-like object. */
|
|
165
285
|
export function resolveTraceContextInput(
|
|
166
286
|
target: unknown,
|