@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
package/src/client/client.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type StandardSchemaV1,
|
|
13
13
|
} from "../contracts/index.js";
|
|
14
14
|
import { isErrorResponseBody, SchemaValidationError } from "../errors/index.js";
|
|
15
|
+
import { encodeQueryValue } from "../query-codec.js";
|
|
15
16
|
import type {
|
|
16
17
|
CallArgs,
|
|
17
18
|
ClientConfig,
|
|
@@ -948,13 +949,7 @@ export class Endpoint<
|
|
|
948
949
|
const normalizedQuery = queryToSerialize ?? query;
|
|
949
950
|
for (const [key, value] of Object.entries(normalizedQuery)) {
|
|
950
951
|
if (value !== undefined && value !== null) {
|
|
951
|
-
|
|
952
|
-
for (const v of value) {
|
|
953
|
-
params.append(key, this.serializeQueryParam(key, v));
|
|
954
|
-
}
|
|
955
|
-
} else {
|
|
956
|
-
params.append(key, this.serializeQueryParam(key, value));
|
|
957
|
-
}
|
|
952
|
+
params.append(key, this.serializeQueryParam(key, value));
|
|
958
953
|
}
|
|
959
954
|
}
|
|
960
955
|
const queryString = params.toString();
|
|
@@ -972,31 +967,11 @@ export class Endpoint<
|
|
|
972
967
|
}
|
|
973
968
|
|
|
974
969
|
private serializeQueryParam(key: string, value: unknown): string {
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
) {
|
|
980
|
-
return String(value);
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
if (value instanceof Date) {
|
|
984
|
-
if (!Number.isFinite(value.getTime())) {
|
|
985
|
-
throw this.invalidQueryParam(key, "contains an invalid Date");
|
|
986
|
-
}
|
|
987
|
-
return value.toISOString();
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
if (typeof value === "object" && value !== null) {
|
|
991
|
-
try {
|
|
992
|
-
const serialized = JSON.stringify(value);
|
|
993
|
-
if (serialized !== undefined) return serialized;
|
|
994
|
-
} catch {
|
|
995
|
-
// Report the contract/query key below instead of leaking JSON internals.
|
|
996
|
-
}
|
|
970
|
+
try {
|
|
971
|
+
return encodeQueryValue(value);
|
|
972
|
+
} catch {
|
|
973
|
+
throw this.invalidQueryParam(key, "contains a non-serializable value");
|
|
997
974
|
}
|
|
998
|
-
|
|
999
|
-
throw this.invalidQueryParam(key, "contains a non-serializable value");
|
|
1000
975
|
}
|
|
1001
976
|
|
|
1002
977
|
private invalidQueryParam(key: string, reason: string): ContractError {
|
|
@@ -5,6 +5,11 @@ import {
|
|
|
5
5
|
mergeCatalogErrors,
|
|
6
6
|
responsesFromErrors,
|
|
7
7
|
} from "./catalog-errors.js";
|
|
8
|
+
import {
|
|
9
|
+
assertValidContractDeprecation,
|
|
10
|
+
assertValidContractLifecycle,
|
|
11
|
+
type ContractDeprecationMeta,
|
|
12
|
+
} from "./lifecycle.js";
|
|
8
13
|
import { mergeContractMeta } from "./metadata.js";
|
|
9
14
|
import type { OpenAPIOperationMeta } from "./openapi-meta.js";
|
|
10
15
|
import { parsePathTemplate } from "./path-template.js";
|
|
@@ -68,6 +73,7 @@ export class ContractBuilder<
|
|
|
68
73
|
THeaders
|
|
69
74
|
>,
|
|
70
75
|
) {
|
|
76
|
+
assertValidContractLifecycle(config);
|
|
71
77
|
if (config.body && !methodSupportsRequestBody(config.method)) {
|
|
72
78
|
throw new Error(
|
|
73
79
|
`Request bodies are not supported for ${config.method} contracts. Use POST, PUT, or PATCH for contract request bodies.`,
|
|
@@ -457,6 +463,38 @@ export class ContractBuilder<
|
|
|
457
463
|
});
|
|
458
464
|
}
|
|
459
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Mark this contract as deprecated for external clients.
|
|
468
|
+
*/
|
|
469
|
+
deprecated<const TDeprecation extends ContractDeprecationMeta>(
|
|
470
|
+
deprecation: TDeprecation,
|
|
471
|
+
): ContractBuilder<
|
|
472
|
+
TMethod,
|
|
473
|
+
TPathParams,
|
|
474
|
+
TQuery,
|
|
475
|
+
TBody,
|
|
476
|
+
THeaders,
|
|
477
|
+
TResponses,
|
|
478
|
+
MergeContractMeta<TMeta, { deprecation: TDeprecation }>,
|
|
479
|
+
TPath
|
|
480
|
+
> {
|
|
481
|
+
assertValidContractDeprecation(deprecation, this.name);
|
|
482
|
+
return new ContractBuilder({
|
|
483
|
+
kind: this.kind,
|
|
484
|
+
name: this.name,
|
|
485
|
+
namespace: this.namespace,
|
|
486
|
+
localName: this.localName,
|
|
487
|
+
method: this.method,
|
|
488
|
+
path: this._path,
|
|
489
|
+
pathParams: this._pathParams,
|
|
490
|
+
query: this._query,
|
|
491
|
+
headers: this._headers,
|
|
492
|
+
body: this._body,
|
|
493
|
+
responses: this._responses,
|
|
494
|
+
metadata: mergeContractMeta(this._meta, { deprecation }),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
460
498
|
/**
|
|
461
499
|
* Merge OpenAPI operation metadata into this contract.
|
|
462
500
|
*/
|
|
@@ -5,6 +5,10 @@ import {
|
|
|
5
5
|
responsesFromErrors,
|
|
6
6
|
} from "./catalog-errors.js";
|
|
7
7
|
import { ContractBuilder } from "./contract-builder.js";
|
|
8
|
+
import {
|
|
9
|
+
assertValidContractDeprecation,
|
|
10
|
+
type ContractDeprecationMeta,
|
|
11
|
+
} from "./lifecycle.js";
|
|
8
12
|
import { mergeContractMeta } from "./metadata.js";
|
|
9
13
|
import { parsePathTemplate } from "./path-template.js";
|
|
10
14
|
import type {
|
|
@@ -164,6 +168,27 @@ export class ContractGroup<
|
|
|
164
168
|
});
|
|
165
169
|
}
|
|
166
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Mark every contract created from this group as deprecated.
|
|
173
|
+
*/
|
|
174
|
+
deprecated<const TDeprecation extends ContractDeprecationMeta>(
|
|
175
|
+
deprecation: TDeprecation,
|
|
176
|
+
): ContractGroup<
|
|
177
|
+
TSharedResponses,
|
|
178
|
+
MergeContractMeta<TSharedMeta, { deprecation: TDeprecation }>,
|
|
179
|
+
TSharedHeaders,
|
|
180
|
+
TPathPrefix
|
|
181
|
+
> {
|
|
182
|
+
assertValidContractDeprecation(deprecation, this._namespace || "group");
|
|
183
|
+
return new ContractGroup({
|
|
184
|
+
namespace: this._namespace,
|
|
185
|
+
meta: mergeContractMeta(this._meta, { deprecation }),
|
|
186
|
+
responses: this._responses,
|
|
187
|
+
headers: this._headers,
|
|
188
|
+
pathPrefix: this._pathPrefix,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
167
192
|
/**
|
|
168
193
|
* Add shared route-owned response schemas to contracts created from this group.
|
|
169
194
|
*
|
package/src/contracts/index.ts
CHANGED
|
@@ -35,6 +35,14 @@ export {
|
|
|
35
35
|
type ResolveContract,
|
|
36
36
|
resolveContract,
|
|
37
37
|
} from "./contract-like.js";
|
|
38
|
+
/**
|
|
39
|
+
* Contract lifecycle metadata and errors.
|
|
40
|
+
*/
|
|
41
|
+
export {
|
|
42
|
+
type ContractDeprecationMeta,
|
|
43
|
+
ContractLifecycleError,
|
|
44
|
+
type ContractLifecycleFindingCode,
|
|
45
|
+
} from "./lifecycle.js";
|
|
38
46
|
/**
|
|
39
47
|
* OpenAPI operation metadata type.
|
|
40
48
|
*/
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { HttpContractConfig } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lifecycle metadata for an HTTP contract that external clients should stop
|
|
5
|
+
* using.
|
|
6
|
+
*/
|
|
7
|
+
export type ContractDeprecationMeta = {
|
|
8
|
+
/** UTC ISO 8601 timestamp when the contract became deprecated. */
|
|
9
|
+
since: string;
|
|
10
|
+
/** Optional human-readable explanation. */
|
|
11
|
+
reason?: string;
|
|
12
|
+
/** UTC ISO 8601 timestamp after which the contract may stop being served. */
|
|
13
|
+
sunset?: string;
|
|
14
|
+
/** URI reference for the preferred replacement operation. */
|
|
15
|
+
replacement?: string;
|
|
16
|
+
/** Absolute HTTP(S) URL with migration or deprecation documentation. */
|
|
17
|
+
documentation?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Stable code identifying invalid lifecycle or operation metadata. */
|
|
21
|
+
export type ContractLifecycleFindingCode =
|
|
22
|
+
| "INVALID_DEPRECATION_SINCE"
|
|
23
|
+
| "INVALID_DEPRECATION_SUNSET"
|
|
24
|
+
| "DEPRECATION_SUNSET_BEFORE_SINCE"
|
|
25
|
+
| "INVALID_DEPRECATION_REASON"
|
|
26
|
+
| "INVALID_DEPRECATION_REPLACEMENT"
|
|
27
|
+
| "INVALID_DEPRECATION_DOCUMENTATION"
|
|
28
|
+
| "INVALID_OPERATION_ID";
|
|
29
|
+
|
|
30
|
+
/** Error raised when contract lifecycle metadata is malformed. */
|
|
31
|
+
export class ContractLifecycleError extends Error {
|
|
32
|
+
/** Stable machine-readable finding code. */
|
|
33
|
+
readonly code: ContractLifecycleFindingCode;
|
|
34
|
+
/** Name of the invalid contract, or the group label during group setup. */
|
|
35
|
+
readonly contract: string;
|
|
36
|
+
|
|
37
|
+
constructor(args: {
|
|
38
|
+
code: ContractLifecycleFindingCode;
|
|
39
|
+
contract: string;
|
|
40
|
+
message: string;
|
|
41
|
+
}) {
|
|
42
|
+
super(args.message);
|
|
43
|
+
this.name = "ContractLifecycleError";
|
|
44
|
+
this.code = args.code;
|
|
45
|
+
this.contract = args.contract;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ISO_8601_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
|
|
50
|
+
const INVALID_URI_REFERENCE_CHARACTERS = /[\s<>"\\]/;
|
|
51
|
+
const INVALID_PERCENT_ENCODING = /%(?![0-9A-Fa-f]{2})/;
|
|
52
|
+
|
|
53
|
+
function parseUtcTimestamp(value: string): number | undefined {
|
|
54
|
+
if (!ISO_8601_UTC.test(value)) return undefined;
|
|
55
|
+
const timestamp = Date.parse(value);
|
|
56
|
+
if (!Number.isFinite(timestamp)) return undefined;
|
|
57
|
+
|
|
58
|
+
const canonical = new Date(timestamp).toISOString();
|
|
59
|
+
if (value !== canonical && value !== canonical.replace(".000Z", "Z")) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return timestamp;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertNonEmptyOptionalString(args: {
|
|
67
|
+
value: unknown;
|
|
68
|
+
field: "reason" | "replacement";
|
|
69
|
+
code: ContractLifecycleFindingCode;
|
|
70
|
+
contract: string;
|
|
71
|
+
}): string | undefined {
|
|
72
|
+
if (args.value === undefined) return undefined;
|
|
73
|
+
if (typeof args.value !== "string" || args.value.trim().length === 0) {
|
|
74
|
+
throw new ContractLifecycleError({
|
|
75
|
+
code: args.code,
|
|
76
|
+
contract: args.contract,
|
|
77
|
+
message: `Contract "${args.contract}" deprecation ${args.field} must be a non-empty string.`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return args.value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Validate deprecation metadata supplied by builders or raw contract configs. */
|
|
84
|
+
export function assertValidContractDeprecation(
|
|
85
|
+
deprecation: unknown,
|
|
86
|
+
contract: string,
|
|
87
|
+
): asserts deprecation is ContractDeprecationMeta {
|
|
88
|
+
if (typeof deprecation !== "object" || deprecation === null) {
|
|
89
|
+
throw new ContractLifecycleError({
|
|
90
|
+
code: "INVALID_DEPRECATION_SINCE",
|
|
91
|
+
contract,
|
|
92
|
+
message: `Contract "${contract}" deprecation metadata must include a valid "since" timestamp.`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const metadata = deprecation as Record<string, unknown>;
|
|
97
|
+
const since =
|
|
98
|
+
typeof metadata.since === "string"
|
|
99
|
+
? parseUtcTimestamp(metadata.since)
|
|
100
|
+
: undefined;
|
|
101
|
+
if (since === undefined) {
|
|
102
|
+
throw new ContractLifecycleError({
|
|
103
|
+
code: "INVALID_DEPRECATION_SINCE",
|
|
104
|
+
contract,
|
|
105
|
+
message: `Contract "${contract}" deprecation "since" must be a valid UTC ISO 8601 timestamp such as 2026-07-11T00:00:00Z.`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let sunset: number | undefined;
|
|
110
|
+
if (metadata.sunset !== undefined) {
|
|
111
|
+
sunset =
|
|
112
|
+
typeof metadata.sunset === "string"
|
|
113
|
+
? parseUtcTimestamp(metadata.sunset)
|
|
114
|
+
: undefined;
|
|
115
|
+
if (sunset === undefined) {
|
|
116
|
+
throw new ContractLifecycleError({
|
|
117
|
+
code: "INVALID_DEPRECATION_SUNSET",
|
|
118
|
+
contract,
|
|
119
|
+
message: `Contract "${contract}" deprecation "sunset" must be a valid UTC ISO 8601 timestamp.`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (sunset < since) {
|
|
123
|
+
throw new ContractLifecycleError({
|
|
124
|
+
code: "DEPRECATION_SUNSET_BEFORE_SINCE",
|
|
125
|
+
contract,
|
|
126
|
+
message: `Contract "${contract}" deprecation "sunset" must not be earlier than "since".`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
assertNonEmptyOptionalString({
|
|
132
|
+
value: metadata.reason,
|
|
133
|
+
field: "reason",
|
|
134
|
+
code: "INVALID_DEPRECATION_REASON",
|
|
135
|
+
contract,
|
|
136
|
+
});
|
|
137
|
+
const replacement = assertNonEmptyOptionalString({
|
|
138
|
+
value: metadata.replacement,
|
|
139
|
+
field: "replacement",
|
|
140
|
+
code: "INVALID_DEPRECATION_REPLACEMENT",
|
|
141
|
+
contract,
|
|
142
|
+
});
|
|
143
|
+
let replacementIsValid = true;
|
|
144
|
+
if (replacement) {
|
|
145
|
+
try {
|
|
146
|
+
new URL(replacement, "https://beignet.invalid");
|
|
147
|
+
} catch {
|
|
148
|
+
replacementIsValid = false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
replacement &&
|
|
153
|
+
(!replacementIsValid ||
|
|
154
|
+
INVALID_URI_REFERENCE_CHARACTERS.test(replacement) ||
|
|
155
|
+
INVALID_PERCENT_ENCODING.test(replacement))
|
|
156
|
+
) {
|
|
157
|
+
throw new ContractLifecycleError({
|
|
158
|
+
code: "INVALID_DEPRECATION_REPLACEMENT",
|
|
159
|
+
contract,
|
|
160
|
+
message: `Contract "${contract}" deprecation "replacement" must be a valid URI reference without whitespace.`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (metadata.documentation !== undefined) {
|
|
165
|
+
let documentation: URL | undefined;
|
|
166
|
+
if (typeof metadata.documentation === "string") {
|
|
167
|
+
try {
|
|
168
|
+
documentation = new URL(metadata.documentation);
|
|
169
|
+
} catch {
|
|
170
|
+
documentation = undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
!documentation ||
|
|
175
|
+
(documentation.protocol !== "http:" &&
|
|
176
|
+
documentation.protocol !== "https:") ||
|
|
177
|
+
INVALID_URI_REFERENCE_CHARACTERS.test(metadata.documentation as string)
|
|
178
|
+
) {
|
|
179
|
+
throw new ContractLifecycleError({
|
|
180
|
+
code: "INVALID_DEPRECATION_DOCUMENTATION",
|
|
181
|
+
contract,
|
|
182
|
+
message: `Contract "${contract}" deprecation "documentation" must be an absolute HTTP(S) URL.`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Return and validate the operation ID used by OpenAPI and route registries. */
|
|
189
|
+
export function getContractOperationId(
|
|
190
|
+
contract: Pick<HttpContractConfig, "name" | "metadata">,
|
|
191
|
+
): string {
|
|
192
|
+
const operationId = contract.metadata.openapi?.operationId ?? contract.name;
|
|
193
|
+
if (
|
|
194
|
+
typeof operationId !== "string" ||
|
|
195
|
+
operationId.trim().length === 0 ||
|
|
196
|
+
operationId !== operationId.trim()
|
|
197
|
+
) {
|
|
198
|
+
throw new ContractLifecycleError({
|
|
199
|
+
code: "INVALID_OPERATION_ID",
|
|
200
|
+
contract: contract.name,
|
|
201
|
+
message: `Contract "${contract.name}" operationId must be a non-empty string without surrounding whitespace.`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return operationId;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Validate lifecycle metadata on a complete contract definition. */
|
|
208
|
+
export function assertValidContractLifecycle(
|
|
209
|
+
contract: Pick<HttpContractConfig, "name" | "metadata">,
|
|
210
|
+
): void {
|
|
211
|
+
getContractOperationId(contract);
|
|
212
|
+
const deprecation = contract.metadata.deprecation;
|
|
213
|
+
if (deprecation !== undefined) {
|
|
214
|
+
assertValidContractDeprecation(deprecation, contract.name);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Build standards-based HTTP response headers for a deprecated contract. */
|
|
219
|
+
export function contractLifecycleResponseHeaders(
|
|
220
|
+
contract: Pick<HttpContractConfig, "name" | "metadata">,
|
|
221
|
+
): Record<string, string> {
|
|
222
|
+
const deprecation = contract.metadata.deprecation;
|
|
223
|
+
if (deprecation === undefined) return {};
|
|
224
|
+
assertValidContractDeprecation(deprecation, contract.name);
|
|
225
|
+
|
|
226
|
+
const headers: Record<string, string> = {
|
|
227
|
+
Deprecation: `@${Math.floor(Date.parse(deprecation.since) / 1000)}`,
|
|
228
|
+
};
|
|
229
|
+
if (deprecation.sunset) {
|
|
230
|
+
headers.Sunset = new Date(deprecation.sunset).toUTCString();
|
|
231
|
+
}
|
|
232
|
+
if (deprecation.documentation) {
|
|
233
|
+
headers.Link = `<${deprecation.documentation}>; rel="deprecation"`;
|
|
234
|
+
}
|
|
235
|
+
return headers;
|
|
236
|
+
}
|
package/src/contracts/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
2
|
import type { IdempotencyMeta } from "../idempotency/index.js";
|
|
3
|
+
import type { ContractDeprecationMeta } from "./lifecycle.js";
|
|
3
4
|
import type { OpenAPIOperationMeta } from "./openapi-meta.js";
|
|
4
5
|
import type { RateLimitMeta } from "./rate-limit.js";
|
|
5
6
|
|
|
@@ -228,6 +229,10 @@ export type ContractMeta = {
|
|
|
228
229
|
* OpenAPI operation metadata.
|
|
229
230
|
*/
|
|
230
231
|
openapi?: OpenAPIOperationMeta;
|
|
232
|
+
/**
|
|
233
|
+
* External API lifecycle metadata for a deprecated contract.
|
|
234
|
+
*/
|
|
235
|
+
deprecation?: ContractDeprecationMeta;
|
|
231
236
|
/**
|
|
232
237
|
* Optional rate limit configuration for this contract.
|
|
233
238
|
*
|
|
@@ -4,6 +4,18 @@
|
|
|
4
4
|
* Provider-neutral error reporting primitives for Beignet applications.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { type RedactionOptions, redactValue } from "../ports/redaction.js";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_ERROR_REPORTING_TIMEOUT_MS,
|
|
10
|
+
errorReportingObserverTimeout,
|
|
11
|
+
runErrorReportingOperation,
|
|
12
|
+
} from "./internal.js";
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
DEFAULT_ERROR_REPORTING_TIMEOUT_MS,
|
|
16
|
+
ErrorReportingTimeoutError,
|
|
17
|
+
} from "./internal.js";
|
|
18
|
+
|
|
7
19
|
type MaybePromise<T> = T | Promise<T>;
|
|
8
20
|
|
|
9
21
|
/**
|
|
@@ -67,6 +79,33 @@ export type ErrorReportOptions = {
|
|
|
67
79
|
traceparent?: string;
|
|
68
80
|
};
|
|
69
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Arguments accepted by `tryReportException(...)`.
|
|
84
|
+
*/
|
|
85
|
+
export interface TryReportExceptionOptions {
|
|
86
|
+
/** Reporter that owns the capture. Omit it to make reporting a no-op. */
|
|
87
|
+
reporter?: ErrorReporterPort | ErrorReporterResolver;
|
|
88
|
+
/** Original application or infrastructure error. */
|
|
89
|
+
error: unknown;
|
|
90
|
+
/** Structured metadata attached to the report. */
|
|
91
|
+
reportOptions?: ErrorReportOptions;
|
|
92
|
+
/**
|
|
93
|
+
* Maximum time allowed for capture and, separately, the failure observer.
|
|
94
|
+
* Set to `false` only when the reporting implementation is intentionally
|
|
95
|
+
* unbounded.
|
|
96
|
+
*
|
|
97
|
+
* @default 1000
|
|
98
|
+
*/
|
|
99
|
+
timeoutMs?: number | false;
|
|
100
|
+
/**
|
|
101
|
+
* Observer for reporter failures. Observer failures are also isolated.
|
|
102
|
+
*/
|
|
103
|
+
onReporterError?: (args: {
|
|
104
|
+
error: unknown;
|
|
105
|
+
reportingError: unknown;
|
|
106
|
+
}) => MaybePromise<void>;
|
|
107
|
+
}
|
|
108
|
+
|
|
70
109
|
/**
|
|
71
110
|
* Result returned by a reporting provider after capture.
|
|
72
111
|
*/
|
|
@@ -102,6 +141,11 @@ export type ErrorReporterPort = {
|
|
|
102
141
|
flush(options?: ErrorReporterFlushOptions): Promise<boolean>;
|
|
103
142
|
};
|
|
104
143
|
|
|
144
|
+
/** Lazy reporter resolver evaluated inside the best-effort capture deadline. */
|
|
145
|
+
export type ErrorReporterResolver = () => MaybePromise<
|
|
146
|
+
ErrorReporterPort | undefined
|
|
147
|
+
>;
|
|
148
|
+
|
|
105
149
|
/**
|
|
106
150
|
* Captured exception stored by `createMemoryErrorReporter(...)`.
|
|
107
151
|
*/
|
|
@@ -252,6 +296,73 @@ export function reportMessage(
|
|
|
252
296
|
return reporter.captureMessage(message, options);
|
|
253
297
|
}
|
|
254
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Best-effort exception capture for runtime boundaries.
|
|
301
|
+
*
|
|
302
|
+
* Missing reporters, reporter failures, and reporter-failure observer errors
|
|
303
|
+
* resolve to `undefined` so diagnostics cannot replace application behavior.
|
|
304
|
+
*/
|
|
305
|
+
export async function tryReportException(
|
|
306
|
+
options: TryReportExceptionOptions,
|
|
307
|
+
): Promise<ErrorReportResult | undefined> {
|
|
308
|
+
const reporterSource = options.reporter;
|
|
309
|
+
if (!reporterSource) return undefined;
|
|
310
|
+
|
|
311
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_ERROR_REPORTING_TIMEOUT_MS;
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
return await runErrorReportingOperation(async () => {
|
|
315
|
+
const reporter =
|
|
316
|
+
typeof reporterSource === "function"
|
|
317
|
+
? await reporterSource()
|
|
318
|
+
: reporterSource;
|
|
319
|
+
if (!reporter) return undefined;
|
|
320
|
+
return reporter.captureException(options.error, options.reportOptions);
|
|
321
|
+
}, timeoutMs);
|
|
322
|
+
} catch (reportingError) {
|
|
323
|
+
try {
|
|
324
|
+
if (options.onReporterError) {
|
|
325
|
+
await runErrorReportingOperation(
|
|
326
|
+
() =>
|
|
327
|
+
options.onReporterError?.({
|
|
328
|
+
error: options.error,
|
|
329
|
+
reportingError,
|
|
330
|
+
}),
|
|
331
|
+
errorReportingObserverTimeout(timeoutMs),
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
// Reporter failure observers must not replace application behavior.
|
|
336
|
+
}
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Redact structured error-report metadata with Beignet's shared sensitive-key
|
|
343
|
+
* rules. The original exception is intentionally not part of this operation.
|
|
344
|
+
*/
|
|
345
|
+
export function redactErrorReportOptions(
|
|
346
|
+
options: ErrorReportOptions,
|
|
347
|
+
redactionOptions: RedactionOptions = {},
|
|
348
|
+
): ErrorReportOptions {
|
|
349
|
+
return {
|
|
350
|
+
...options,
|
|
351
|
+
user: options.user
|
|
352
|
+
? redactValue(options.user, redactionOptions)
|
|
353
|
+
: options.user,
|
|
354
|
+
tags: options.tags
|
|
355
|
+
? redactValue(options.tags, redactionOptions)
|
|
356
|
+
: options.tags,
|
|
357
|
+
contexts: options.contexts
|
|
358
|
+
? redactValue(options.contexts, redactionOptions)
|
|
359
|
+
: options.contexts,
|
|
360
|
+
extra: options.extra
|
|
361
|
+
? redactValue(options.extra, redactionOptions)
|
|
362
|
+
: options.extra,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
255
366
|
function withAmbientState(
|
|
256
367
|
port: MemoryErrorReporterPort,
|
|
257
368
|
options: ErrorReportOptions | undefined,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_ERROR_REPORTING_TIMEOUT_MS = 1000;
|
|
4
|
+
|
|
5
|
+
export class ErrorReportingTimeoutError extends Error {
|
|
6
|
+
readonly code = "ERROR_REPORTING_TIMEOUT" as const;
|
|
7
|
+
readonly timeoutMs: number;
|
|
8
|
+
|
|
9
|
+
constructor(timeoutMs: number) {
|
|
10
|
+
super(`Error reporting timed out after ${timeoutMs}ms.`);
|
|
11
|
+
this.name = "ErrorReportingTimeoutError";
|
|
12
|
+
this.timeoutMs = timeoutMs;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function errorReportingObserverTimeout(
|
|
17
|
+
timeoutMs: number | false,
|
|
18
|
+
): number | false {
|
|
19
|
+
return timeoutMs === false || (Number.isFinite(timeoutMs) && timeoutMs > 0)
|
|
20
|
+
? timeoutMs
|
|
21
|
+
: DEFAULT_ERROR_REPORTING_TIMEOUT_MS;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function runErrorReportingOperation<T>(
|
|
25
|
+
operation: () => MaybePromise<T>,
|
|
26
|
+
timeoutMs: number | false = DEFAULT_ERROR_REPORTING_TIMEOUT_MS,
|
|
27
|
+
): Promise<T> {
|
|
28
|
+
if (timeoutMs === false) return operation();
|
|
29
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
30
|
+
throw new TypeError("Error reporting timeoutMs must be a positive number.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
34
|
+
try {
|
|
35
|
+
return await Promise.race([
|
|
36
|
+
Promise.resolve().then(operation),
|
|
37
|
+
new Promise<never>((_, reject) => {
|
|
38
|
+
timeout = setTimeout(
|
|
39
|
+
() => reject(new ErrorReportingTimeoutError(timeoutMs)),
|
|
40
|
+
timeoutMs,
|
|
41
|
+
);
|
|
42
|
+
}),
|
|
43
|
+
]);
|
|
44
|
+
} finally {
|
|
45
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/events/index.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
2
|
import { runWithResolvedTracingContext } from "../tracing/execution.js";
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
parseTraceCarrier,
|
|
5
|
+
type TraceCarrier,
|
|
6
|
+
type TracingPort,
|
|
7
|
+
} from "../tracing/index.js";
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* Any Standard Schema compatible validator.
|
|
@@ -58,6 +62,12 @@ export interface EventDef<
|
|
|
58
62
|
export type InferEventPayload<E extends EventPayloadDef> =
|
|
59
63
|
E["payload"] extends StandardSchemaV1<unknown, infer Output> ? Output : never;
|
|
60
64
|
|
|
65
|
+
/** Metadata propagated with an event delivery. */
|
|
66
|
+
export interface EventPublishOptions {
|
|
67
|
+
/** Versioned trace context captured by the event producer. */
|
|
68
|
+
trace?: TraceCarrier;
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
/**
|
|
62
72
|
* Options for `defineEvent(...)`.
|
|
63
73
|
*/
|
|
@@ -140,13 +150,17 @@ export interface EventBusLike {
|
|
|
140
150
|
publish<E extends EventPayloadDef>(
|
|
141
151
|
event: E,
|
|
142
152
|
payload: InferEventPayload<E>,
|
|
153
|
+
options?: EventPublishOptions,
|
|
143
154
|
): MaybePromise<void>;
|
|
144
155
|
/**
|
|
145
156
|
* Subscribe to an event and return an unsubscribe function.
|
|
146
157
|
*/
|
|
147
158
|
subscribe<E extends EventPayloadDef>(
|
|
148
159
|
event: E,
|
|
149
|
-
handler: (
|
|
160
|
+
handler: (
|
|
161
|
+
payload: InferEventPayload<E>,
|
|
162
|
+
options?: EventPublishOptions,
|
|
163
|
+
) => MaybePromise<void>,
|
|
150
164
|
): () => void;
|
|
151
165
|
}
|
|
152
166
|
|
|
@@ -299,9 +313,10 @@ export async function publishEvent<E extends EventPayloadDef>(
|
|
|
299
313
|
eventBus: EventBusLike,
|
|
300
314
|
event: E,
|
|
301
315
|
payload: InferEventPayload<E>,
|
|
316
|
+
options?: EventPublishOptions,
|
|
302
317
|
): Promise<void> {
|
|
303
318
|
const parsed = await parseEventPayload(event, payload);
|
|
304
|
-
await eventBus.publish(event, parsed);
|
|
319
|
+
await eventBus.publish(event, parsed, options);
|
|
305
320
|
}
|
|
306
321
|
|
|
307
322
|
/**
|
|
@@ -316,7 +331,7 @@ export function registerListeners<Ctx>(
|
|
|
316
331
|
options: RegisterListenersOptions<Ctx> = {},
|
|
317
332
|
): () => void {
|
|
318
333
|
const unsubscribes = listeners.map((listener) =>
|
|
319
|
-
eventBus.subscribe(listener.event, async (rawPayload) => {
|
|
334
|
+
eventBus.subscribe(listener.event, async (rawPayload, publishOptions) => {
|
|
320
335
|
try {
|
|
321
336
|
const payload = await parseEventPayload(listener.event, rawPayload);
|
|
322
337
|
const traceAttributes = {
|
|
@@ -330,6 +345,7 @@ export function registerListeners<Ctx>(
|
|
|
330
345
|
name: `beignet.listener ${listener.name}`,
|
|
331
346
|
type: "listener",
|
|
332
347
|
kind: "consumer",
|
|
348
|
+
parent: parseTraceCarrier(publishOptions?.trace),
|
|
333
349
|
attributes: traceAttributes,
|
|
334
350
|
metricAttributes: traceAttributes,
|
|
335
351
|
},
|