@beignet/core 0.0.48 → 0.0.50
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 +46 -0
- package/README.md +224 -20
- package/dist/client/client.d.ts +0 -2
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +28 -25
- package/dist/client/client.js.map +1 -1
- package/dist/contracts/contract-builder.d.ts +7 -2
- package/dist/contracts/contract-builder.d.ts.map +1 -1
- package/dist/contracts/contract-builder.js +20 -2
- package/dist/contracts/contract-builder.js.map +1 -1
- package/dist/contracts/contract-group.d.ts.map +1 -1
- package/dist/contracts/contract-group.js +1 -0
- package/dist/contracts/contract-group.js.map +1 -1
- package/dist/contracts/contract-like.d.ts +2 -0
- package/dist/contracts/contract-like.d.ts.map +1 -1
- package/dist/contracts/contract-like.js +27 -1
- package/dist/contracts/contract-like.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/query-transport.d.ts +126 -0
- package/dist/contracts/query-transport.d.ts.map +1 -0
- package/dist/contracts/query-transport.js +406 -0
- package/dist/contracts/query-transport.js.map +1 -0
- package/dist/contracts/schema-shape.d.ts +11 -0
- package/dist/contracts/schema-shape.d.ts.map +1 -1
- package/dist/contracts/schema-shape.js +13 -0
- package/dist/contracts/schema-shape.js.map +1 -1
- 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/events/index.d.ts +54 -5
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +183 -32
- package/dist/events/index.js.map +1 -1
- package/dist/idempotency/index.d.ts +7 -3
- package/dist/idempotency/index.d.ts.map +1 -1
- package/dist/idempotency/index.js +45 -12
- package/dist/idempotency/index.js.map +1 -1
- package/dist/mail/index.d.ts.map +1 -1
- package/dist/mail/index.js +6 -3
- package/dist/mail/index.js.map +1 -1
- package/dist/openapi/index.d.ts +8 -0
- package/dist/openapi/index.d.ts.map +1 -1
- package/dist/openapi/index.js +79 -5
- package/dist/openapi/index.js.map +1 -1
- package/dist/outbox/index.d.ts +8 -5
- package/dist/outbox/index.d.ts.map +1 -1
- package/dist/outbox/index.js +17 -3
- package/dist/outbox/index.js.map +1 -1
- package/dist/ports/best-effort-work.d.ts +21 -0
- package/dist/ports/best-effort-work.d.ts.map +1 -0
- package/dist/ports/best-effort-work.js +2 -0
- package/dist/ports/best-effort-work.js.map +1 -0
- package/dist/ports/cache.d.ts +9 -1
- package/dist/ports/cache.d.ts.map +1 -1
- package/dist/ports/cache.js +20 -5
- package/dist/ports/cache.js.map +1 -1
- package/dist/ports/events.d.ts +7 -5
- package/dist/ports/events.d.ts.map +1 -1
- package/dist/ports/index.d.ts +7 -2
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/index.js +2 -1
- package/dist/ports/index.js.map +1 -1
- package/dist/ports/testing.d.ts +15 -0
- package/dist/ports/testing.d.ts.map +1 -1
- package/dist/ports/testing.js +38 -0
- package/dist/ports/testing.js.map +1 -1
- package/dist/providers/provider.d.ts +8 -5
- package/dist/providers/provider.d.ts.map +1 -1
- package/dist/providers/provider.js.map +1 -1
- package/dist/server/hooks/cors.d.ts +2 -2
- package/dist/server/hooks/cors.d.ts.map +1 -1
- package/dist/server/hooks/cors.js +2 -1
- package/dist/server/hooks/cors.js.map +1 -1
- package/dist/server/hooks/logging.d.ts +2 -2
- package/dist/server/hooks/logging.d.ts.map +1 -1
- package/dist/server/hooks/logging.js.map +1 -1
- package/dist/server/hooks/rate-limit.d.ts +16 -8
- package/dist/server/hooks/rate-limit.d.ts.map +1 -1
- package/dist/server/hooks/rate-limit.js +31 -17
- package/dist/server/hooks/rate-limit.js.map +1 -1
- package/dist/server/hooks/security.d.ts +2 -2
- package/dist/server/hooks/security.d.ts.map +1 -1
- package/dist/server/hooks/security.js.map +1 -1
- package/dist/server/http.d.ts +21 -2
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +4 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/instrumentation.d.ts.map +1 -1
- package/dist/server/instrumentation.js +5 -3
- package/dist/server/instrumentation.js.map +1 -1
- package/dist/server/request-executor.d.ts.map +1 -1
- package/dist/server/request-executor.js +18 -9
- 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 +31 -11
- package/dist/server/request-preparation.js.map +1 -1
- package/dist/server/response-finalization.d.ts +2 -2
- package/dist/server/response-finalization.d.ts.map +1 -1
- package/dist/server/response-finalization.js +25 -8
- package/dist/server/response-finalization.js.map +1 -1
- package/dist/server/route-matching.d.ts.map +1 -1
- package/dist/server/route-matching.js +12 -1
- package/dist/server/route-matching.js.map +1 -1
- package/dist/server/server-sent-events.d.ts +94 -0
- package/dist/server/server-sent-events.d.ts.map +1 -0
- package/dist/server/server-sent-events.js +275 -0
- package/dist/server/server-sent-events.js.map +1 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +43 -22
- package/dist/server/server.js.map +1 -1
- package/dist/server/trusted-proxy-internal.d.ts +4 -0
- package/dist/server/trusted-proxy-internal.d.ts.map +1 -1
- package/dist/server/trusted-proxy-internal.js +20 -0
- package/dist/server/trusted-proxy-internal.js.map +1 -1
- package/dist/server/trusted-proxy.d.ts.map +1 -1
- package/dist/server/trusted-proxy.js +3 -8
- package/dist/server/trusted-proxy.js.map +1 -1
- package/dist/server/use-case-route.d.ts +8 -5
- package/dist/server/use-case-route.d.ts.map +1 -1
- package/dist/server/use-case-route.js +44 -17
- package/dist/server/use-case-route.js.map +1 -1
- package/dist/testing/index.d.ts +17 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +6 -1
- package/dist/testing/index.js.map +1 -1
- package/package.json +3 -3
- package/skills/app-architecture/SKILL.md +50 -4
- package/src/client/client.ts +29 -28
- package/src/contracts/contract-builder.ts +32 -2
- package/src/contracts/contract-group.ts +1 -0
- package/src/contracts/contract-like.ts +40 -1
- package/src/contracts/index.ts +23 -0
- package/src/contracts/query-transport.ts +697 -0
- package/src/contracts/schema-shape.ts +24 -0
- package/src/contracts/types.ts +5 -0
- package/src/events/index.ts +263 -38
- package/src/idempotency/index.ts +65 -17
- package/src/mail/index.ts +7 -3
- package/src/openapi/index.ts +126 -2
- package/src/outbox/index.ts +26 -5
- package/src/ports/best-effort-work.ts +21 -0
- package/src/ports/cache.ts +29 -7
- package/src/ports/events.ts +9 -4
- package/src/ports/index.ts +10 -1
- package/src/ports/testing.ts +45 -0
- package/src/providers/provider.ts +8 -5
- package/src/server/hooks/cors.ts +11 -5
- package/src/server/hooks/logging.ts +6 -2
- package/src/server/hooks/rate-limit.ts +50 -24
- package/src/server/hooks/security.ts +8 -4
- package/src/server/http.ts +23 -2
- package/src/server/index.ts +4 -0
- package/src/server/instrumentation.ts +12 -4
- package/src/server/request-executor.ts +31 -9
- package/src/server/request-preparation.ts +45 -12
- package/src/server/response-finalization.ts +51 -15
- package/src/server/route-matching.ts +24 -1
- package/src/server/server-sent-events.ts +415 -0
- package/src/server/server.ts +48 -22
- package/src/server/trusted-proxy-internal.ts +20 -0
- package/src/server/trusted-proxy.ts +6 -7
- package/src/server/use-case-route.ts +62 -23
- package/src/testing/index.ts +30 -0
- package/dist/query-codec.d.ts +0 -3
- package/dist/query-codec.d.ts.map +0 -1
- package/dist/query-codec.js +0 -110
- package/dist/query-codec.js.map +0 -1
- package/src/query-codec.ts +0 -130
|
@@ -73,3 +73,27 @@ export function formatPathParamsMismatch(args: {
|
|
|
73
73
|
.filter(Boolean)
|
|
74
74
|
.join("; ");
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
/** Compare an introspected query schema's fields with its transport fields. */
|
|
78
|
+
export function compareQueryTransportFields(args: {
|
|
79
|
+
schemaKeys: readonly string[];
|
|
80
|
+
transportKeys: readonly string[];
|
|
81
|
+
}): boolean {
|
|
82
|
+
const schemaKeys = [...args.schemaKeys].sort();
|
|
83
|
+
const transportKeys = [...args.transportKeys].sort();
|
|
84
|
+
return (
|
|
85
|
+
schemaKeys.length === transportKeys.length &&
|
|
86
|
+
schemaKeys.every((key, index) => key === transportKeys[index])
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Format a query schema/transport mismatch consistently across consumers. */
|
|
91
|
+
export function formatQueryTransportMismatch(args: {
|
|
92
|
+
contractName: string;
|
|
93
|
+
schemaKeys: readonly string[];
|
|
94
|
+
transportKeys: readonly string[];
|
|
95
|
+
}): string {
|
|
96
|
+
const schemaKeys = [...args.schemaKeys].sort();
|
|
97
|
+
const transportKeys = [...args.transportKeys].sort();
|
|
98
|
+
return `Query transport fields for contract "${args.contractName}" must match its query schema fields (schema: ${schemaKeys.join(", ") || "none"}; transport: ${transportKeys.join(", ") || "none"}).`;
|
|
99
|
+
}
|
package/src/contracts/types.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
|
2
2
|
import type { IdempotencyMeta } from "../idempotency/index.js";
|
|
3
3
|
import type { ContractDeprecationMeta } from "./lifecycle.js";
|
|
4
4
|
import type { OpenAPIOperationMeta } from "./openapi-meta.js";
|
|
5
|
+
import type { QueryTransport } from "./query-transport.js";
|
|
5
6
|
import type { RateLimitMeta } from "./rate-limit.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -343,6 +344,10 @@ export type HttpContractConfig<
|
|
|
343
344
|
* Query parameter schema.
|
|
344
345
|
*/
|
|
345
346
|
query: TQuery;
|
|
347
|
+
/**
|
|
348
|
+
* Deterministic HTTP encoding for the query schema.
|
|
349
|
+
*/
|
|
350
|
+
queryTransport: QueryTransport | null;
|
|
346
351
|
/**
|
|
347
352
|
* Request header schema or schemas.
|
|
348
353
|
*/
|
package/src/events/index.ts
CHANGED
|
@@ -16,6 +16,9 @@ export type StandardSchema = StandardSchemaV1<unknown, unknown>;
|
|
|
16
16
|
*/
|
|
17
17
|
export type MaybePromise<T> = T | Promise<T>;
|
|
18
18
|
|
|
19
|
+
const DEFAULT_LISTENER_READY_TIMEOUT_MS = 10_000;
|
|
20
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
21
|
+
|
|
19
22
|
/**
|
|
20
23
|
* Infer the parsed output type from a Standard Schema.
|
|
21
24
|
*/
|
|
@@ -68,6 +71,74 @@ export interface EventPublishOptions {
|
|
|
68
71
|
trace?: TraceCarrier;
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Lifecycle handle for one event subscription or a composed listener
|
|
76
|
+
* registration.
|
|
77
|
+
*
|
|
78
|
+
* `ready` proves initial transport readiness. It does not represent ongoing
|
|
79
|
+
* connectivity, durability, replay, or handler success after startup.
|
|
80
|
+
*/
|
|
81
|
+
export interface EventSubscription {
|
|
82
|
+
/** Resolves when the subscription can receive events. */
|
|
83
|
+
readonly ready: Promise<void>;
|
|
84
|
+
/** Stop local delivery and await transport cleanup. Idempotent. */
|
|
85
|
+
unsubscribe(): Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Error used when a subscription closes before initial readiness. */
|
|
89
|
+
export class EventSubscriptionClosedError extends Error {
|
|
90
|
+
constructor(message = "Event subscription closed before it became ready.") {
|
|
91
|
+
super(message);
|
|
92
|
+
this.name = "EventSubscriptionClosedError";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Error thrown when a listener registry misses its readiness deadline. */
|
|
97
|
+
export class ListenerRegistrationTimeoutError extends Error {
|
|
98
|
+
/** Configured readiness deadline in milliseconds. */
|
|
99
|
+
readonly timeoutMs: number;
|
|
100
|
+
/** Listener names that were part of the registration. */
|
|
101
|
+
readonly listenerNames: readonly string[];
|
|
102
|
+
|
|
103
|
+
constructor(args: {
|
|
104
|
+
timeoutMs: number;
|
|
105
|
+
listenerNames: readonly string[];
|
|
106
|
+
}) {
|
|
107
|
+
const suffix = args.listenerNames.length
|
|
108
|
+
? `: ${args.listenerNames.join(", ")}`
|
|
109
|
+
: "";
|
|
110
|
+
super(
|
|
111
|
+
`Listeners did not become ready within ${args.timeoutMs}ms${suffix}.`,
|
|
112
|
+
);
|
|
113
|
+
this.name = "ListenerRegistrationTimeoutError";
|
|
114
|
+
this.timeoutMs = args.timeoutMs;
|
|
115
|
+
this.listenerNames = [...args.listenerNames];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Error thrown when listener rollback misses the registration deadline. */
|
|
120
|
+
export class ListenerRegistrationCleanupTimeoutError extends Error {
|
|
121
|
+
/** Configured registration deadline in milliseconds. */
|
|
122
|
+
readonly timeoutMs: number;
|
|
123
|
+
/** Listener names that were part of the registration. */
|
|
124
|
+
readonly listenerNames: readonly string[];
|
|
125
|
+
|
|
126
|
+
constructor(args: {
|
|
127
|
+
timeoutMs: number;
|
|
128
|
+
listenerNames: readonly string[];
|
|
129
|
+
}) {
|
|
130
|
+
const suffix = args.listenerNames.length
|
|
131
|
+
? `: ${args.listenerNames.join(", ")}`
|
|
132
|
+
: "";
|
|
133
|
+
super(
|
|
134
|
+
`Listener cleanup did not finish before the ${args.timeoutMs}ms registration deadline${suffix}.`,
|
|
135
|
+
);
|
|
136
|
+
this.name = "ListenerRegistrationCleanupTimeoutError";
|
|
137
|
+
this.timeoutMs = args.timeoutMs;
|
|
138
|
+
this.listenerNames = [...args.listenerNames];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
71
142
|
/**
|
|
72
143
|
* Options for `defineEvent(...)`.
|
|
73
144
|
*/
|
|
@@ -153,7 +224,7 @@ export interface EventBusLike {
|
|
|
153
224
|
options?: EventPublishOptions,
|
|
154
225
|
): MaybePromise<void>;
|
|
155
226
|
/**
|
|
156
|
-
* Subscribe to an event and return
|
|
227
|
+
* Subscribe to an event and return its readiness and cleanup handle.
|
|
157
228
|
*/
|
|
158
229
|
subscribe<E extends EventPayloadDef>(
|
|
159
230
|
event: E,
|
|
@@ -161,7 +232,7 @@ export interface EventBusLike {
|
|
|
161
232
|
payload: InferEventPayload<E>,
|
|
162
233
|
options?: EventPublishOptions,
|
|
163
234
|
) => MaybePromise<void>,
|
|
164
|
-
):
|
|
235
|
+
): EventSubscription;
|
|
165
236
|
}
|
|
166
237
|
|
|
167
238
|
/**
|
|
@@ -182,6 +253,12 @@ export interface RegisterListenersOptions<Ctx> {
|
|
|
182
253
|
* the event bus subscription callback.
|
|
183
254
|
*/
|
|
184
255
|
onError?: (error: unknown, listener: ListenerDef<EventDef, Ctx>) => void;
|
|
256
|
+
/**
|
|
257
|
+
* Maximum time for the complete listener registry to become ready. The same
|
|
258
|
+
* registration deadline bounds automatic rollback after startup failure.
|
|
259
|
+
* Defaults to 10 seconds.
|
|
260
|
+
*/
|
|
261
|
+
readyTimeoutMs?: number;
|
|
185
262
|
}
|
|
186
263
|
|
|
187
264
|
/**
|
|
@@ -319,54 +396,202 @@ export async function publishEvent<E extends EventPayloadDef>(
|
|
|
319
396
|
await eventBus.publish(event, payload, options);
|
|
320
397
|
}
|
|
321
398
|
|
|
399
|
+
function assertReadyTimeoutMs(value: number): void {
|
|
400
|
+
if (Number.isInteger(value) && value >= 1 && value <= MAX_TIMER_MS) return;
|
|
401
|
+
throw new RangeError(
|
|
402
|
+
`readyTimeoutMs must be an integer between 1 and ${MAX_TIMER_MS} milliseconds.`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function errorsFromCleanup(error: unknown): unknown[] {
|
|
407
|
+
return error instanceof AggregateError ? [...error.errors] : [error];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function unsubscribeAllSettled(
|
|
411
|
+
subscriptions: readonly EventSubscription[],
|
|
412
|
+
): Promise<PromiseSettledResult<void>[]> {
|
|
413
|
+
const attempts: Promise<void>[] = [];
|
|
414
|
+
for (const subscription of [...subscriptions].reverse()) {
|
|
415
|
+
try {
|
|
416
|
+
attempts.push(Promise.resolve(subscription.unsubscribe()));
|
|
417
|
+
} catch (error) {
|
|
418
|
+
attempts.push(Promise.reject(error));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return Promise.allSettled(attempts);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function throwSubscriptionCleanupErrors(
|
|
426
|
+
results: readonly PromiseSettledResult<void>[],
|
|
427
|
+
): void {
|
|
428
|
+
const errors = results.flatMap((result) =>
|
|
429
|
+
result.status === "rejected" ? errorsFromCleanup(result.reason) : [],
|
|
430
|
+
);
|
|
431
|
+
if (errors.length > 0) {
|
|
432
|
+
throw new AggregateError(errors, "Event subscription cleanup failed");
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function withListenerDeadline<T>(
|
|
437
|
+
operation: Promise<T>,
|
|
438
|
+
args: {
|
|
439
|
+
deadlineAt: number;
|
|
440
|
+
timeoutError: Error;
|
|
441
|
+
},
|
|
442
|
+
): Promise<T> {
|
|
443
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
444
|
+
const timedOut = new Promise<T>((_, reject) => {
|
|
445
|
+
timeout = setTimeout(
|
|
446
|
+
() => {
|
|
447
|
+
reject(args.timeoutError);
|
|
448
|
+
},
|
|
449
|
+
Math.max(0, args.deadlineAt - performance.now()),
|
|
450
|
+
);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
return Promise.race([operation, timedOut]).finally(() => {
|
|
454
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
322
458
|
/**
|
|
323
|
-
* Register listeners against an event bus and return
|
|
459
|
+
* Register listeners against an event bus and return a composite lifecycle
|
|
460
|
+
* handle.
|
|
324
461
|
*
|
|
325
462
|
* Payloads are validated before listener handlers run. Listener context is
|
|
326
|
-
* resolved per delivery when `options.ctx` is a factory.
|
|
463
|
+
* resolved per delivery when `options.ctx` is a factory. Initial registration
|
|
464
|
+
* starts every child cleanup after a synchronous subscribe failure, rejected
|
|
465
|
+
* readiness promise, or readiness timeout. Cleanup that cannot finish inside
|
|
466
|
+
* the registration deadline is reported without extending startup forever.
|
|
327
467
|
*/
|
|
328
468
|
export function registerListeners<Ctx>(
|
|
329
469
|
eventBus: EventBusLike,
|
|
330
470
|
listeners: readonly ListenerDef<EventDef, Ctx>[],
|
|
331
471
|
options: RegisterListenersOptions<Ctx> = {},
|
|
332
|
-
):
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
472
|
+
): EventSubscription {
|
|
473
|
+
const readyTimeoutMs =
|
|
474
|
+
options.readyTimeoutMs ?? DEFAULT_LISTENER_READY_TIMEOUT_MS;
|
|
475
|
+
assertReadyTimeoutMs(readyTimeoutMs);
|
|
476
|
+
const registrationDeadlineAt = performance.now() + readyTimeoutMs;
|
|
477
|
+
const listenerNames = listeners.map(({ name }) => name);
|
|
478
|
+
|
|
479
|
+
const subscriptions: EventSubscription[] = [];
|
|
480
|
+
let registrationError: unknown;
|
|
481
|
+
|
|
482
|
+
for (const listener of listeners) {
|
|
483
|
+
try {
|
|
484
|
+
subscriptions.push(
|
|
485
|
+
eventBus.subscribe(
|
|
486
|
+
listener.event,
|
|
487
|
+
async (rawPayload, publishOptions) => {
|
|
488
|
+
try {
|
|
489
|
+
const payload = await parseEventPayload(
|
|
490
|
+
listener.event,
|
|
491
|
+
rawPayload,
|
|
492
|
+
);
|
|
493
|
+
const traceAttributes = {
|
|
494
|
+
"beignet.listener.name": listener.name,
|
|
495
|
+
"beignet.event.name": listener.event.name,
|
|
496
|
+
} as const;
|
|
497
|
+
await runWithResolvedTracingContext({
|
|
498
|
+
tracing: options.tracing,
|
|
499
|
+
ctx: options.ctx as Ctx | (() => MaybePromise<Ctx>),
|
|
500
|
+
operation: {
|
|
501
|
+
name: `beignet.listener ${listener.name}`,
|
|
502
|
+
type: "listener",
|
|
503
|
+
kind: "consumer",
|
|
504
|
+
parent: parseTraceCarrier(publishOptions?.trace),
|
|
505
|
+
attributes: traceAttributes,
|
|
506
|
+
metricAttributes: traceAttributes,
|
|
507
|
+
},
|
|
508
|
+
run: (ctx) =>
|
|
509
|
+
listener.handle({
|
|
510
|
+
event: listener.event,
|
|
511
|
+
payload,
|
|
512
|
+
ctx,
|
|
513
|
+
}),
|
|
514
|
+
});
|
|
515
|
+
} catch (error) {
|
|
516
|
+
options.onError?.(error, listener);
|
|
517
|
+
if (!options.onError) throw error;
|
|
518
|
+
}
|
|
351
519
|
},
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
520
|
+
),
|
|
521
|
+
);
|
|
522
|
+
} catch (error) {
|
|
523
|
+
registrationError = error;
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
let rejectClosed!: (error: unknown) => void;
|
|
529
|
+
const closedBeforeReady = new Promise<void>((_, reject) => {
|
|
530
|
+
rejectClosed = reject;
|
|
531
|
+
});
|
|
532
|
+
// A consumer may only await cleanup. Keep an explicit observer on the
|
|
533
|
+
// cancellation signal so that use does not create an unhandled rejection.
|
|
534
|
+
void closedBeforeReady.catch(() => undefined);
|
|
535
|
+
|
|
536
|
+
let cleanupPromise: Promise<void> | undefined;
|
|
537
|
+
const cleanup = (deadlineAt?: number): Promise<void> => {
|
|
538
|
+
if (cleanupPromise) return cleanupPromise;
|
|
539
|
+
const operation = unsubscribeAllSettled(subscriptions);
|
|
540
|
+
cleanupPromise =
|
|
541
|
+
deadlineAt === undefined
|
|
542
|
+
? operation.then(throwSubscriptionCleanupErrors)
|
|
543
|
+
: withListenerDeadline(operation, {
|
|
544
|
+
deadlineAt,
|
|
545
|
+
timeoutError: new ListenerRegistrationCleanupTimeoutError({
|
|
546
|
+
timeoutMs: readyTimeoutMs,
|
|
547
|
+
listenerNames,
|
|
357
548
|
}),
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
549
|
+
}).then(throwSubscriptionCleanupErrors);
|
|
550
|
+
return cleanupPromise;
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const initialReadiness = registrationError
|
|
554
|
+
? Promise.reject(registrationError)
|
|
555
|
+
: Promise.all(subscriptions.map(({ ready }) => ready)).then(
|
|
556
|
+
() => undefined,
|
|
557
|
+
);
|
|
558
|
+
let readySettled = false;
|
|
559
|
+
const ready = withListenerDeadline(
|
|
560
|
+
Promise.race([initialReadiness, closedBeforeReady]),
|
|
561
|
+
{
|
|
562
|
+
deadlineAt: registrationDeadlineAt,
|
|
563
|
+
timeoutError: new ListenerRegistrationTimeoutError({
|
|
564
|
+
timeoutMs: readyTimeoutMs,
|
|
565
|
+
listenerNames,
|
|
566
|
+
}),
|
|
567
|
+
},
|
|
568
|
+
)
|
|
569
|
+
.catch(async (primaryError: unknown) => {
|
|
570
|
+
try {
|
|
571
|
+
await cleanup(registrationDeadlineAt);
|
|
572
|
+
} catch (cleanupError) {
|
|
573
|
+
throw new AggregateError(
|
|
574
|
+
[primaryError, ...errorsFromCleanup(cleanupError)],
|
|
575
|
+
"Listener registration failed and cleanup failed",
|
|
576
|
+
);
|
|
362
577
|
}
|
|
363
|
-
|
|
364
|
-
|
|
578
|
+
throw primaryError;
|
|
579
|
+
})
|
|
580
|
+
.finally(() => {
|
|
581
|
+
readySettled = true;
|
|
582
|
+
});
|
|
583
|
+
// EventSubscription allows callers that only own cleanup. Preserve the
|
|
584
|
+
// rejection for awaiters while preventing process-level unhandled noise.
|
|
585
|
+
void ready.catch(() => undefined);
|
|
365
586
|
|
|
366
|
-
return
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
587
|
+
return {
|
|
588
|
+
ready,
|
|
589
|
+
unsubscribe() {
|
|
590
|
+
if (!readySettled) {
|
|
591
|
+
rejectClosed(new EventSubscriptionClosedError());
|
|
592
|
+
}
|
|
593
|
+
return cleanup(readySettled ? undefined : registrationDeadlineAt);
|
|
594
|
+
},
|
|
370
595
|
};
|
|
371
596
|
}
|
|
372
597
|
|
package/src/idempotency/index.ts
CHANGED
|
@@ -451,6 +451,17 @@ type CanonicalValue =
|
|
|
451
451
|
| readonly CanonicalValue[]
|
|
452
452
|
| { readonly [key: string]: CanonicalValue };
|
|
453
453
|
|
|
454
|
+
const CANONICAL_TYPE_KEY = "$beignet:idempotency-type";
|
|
455
|
+
|
|
456
|
+
function canonicalType(
|
|
457
|
+
type: "bigint" | "date" | "negative-zero" | "omitted",
|
|
458
|
+
value?: string,
|
|
459
|
+
): CanonicalValue {
|
|
460
|
+
return {
|
|
461
|
+
[CANONICAL_TYPE_KEY]: value === undefined ? [type] : [type, value],
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
454
465
|
type MemoryRecord = MemoryIdempotencyEntry & {
|
|
455
466
|
replayTtlSec?: number;
|
|
456
467
|
};
|
|
@@ -875,11 +886,15 @@ function canonicalize(
|
|
|
875
886
|
return undefined;
|
|
876
887
|
}
|
|
877
888
|
|
|
878
|
-
if (
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
) {
|
|
889
|
+
if (value === null) {
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (typeof value === "string") {
|
|
894
|
+
return value;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (typeof value === "boolean") {
|
|
883
898
|
return value;
|
|
884
899
|
}
|
|
885
900
|
|
|
@@ -889,25 +904,54 @@ function canonicalize(
|
|
|
889
904
|
"Cannot fingerprint non-finite numeric values.",
|
|
890
905
|
);
|
|
891
906
|
}
|
|
892
|
-
return value;
|
|
907
|
+
return Object.is(value, -0) ? canonicalType("negative-zero") : value;
|
|
893
908
|
}
|
|
894
909
|
|
|
895
910
|
if (typeof value === "bigint") {
|
|
896
|
-
return value.toString();
|
|
911
|
+
return canonicalType("bigint", value.toString());
|
|
897
912
|
}
|
|
898
913
|
|
|
899
914
|
if (value instanceof Date) {
|
|
900
|
-
|
|
915
|
+
if (!Number.isFinite(value.getTime())) {
|
|
916
|
+
throw new IdempotencyFingerprintError(
|
|
917
|
+
"Cannot fingerprint an invalid Date value.",
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
return canonicalType("date", value.toISOString());
|
|
901
921
|
}
|
|
902
922
|
|
|
903
923
|
if (Array.isArray(value)) {
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
924
|
+
if (seen.has(value)) {
|
|
925
|
+
throw new IdempotencyFingerprintError(
|
|
926
|
+
"Cannot fingerprint circular values.",
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
seen.add(value);
|
|
930
|
+
const result: CanonicalValue = Array.from(value, (item, index) => {
|
|
931
|
+
const nestedValue = canonicalize(
|
|
932
|
+
item,
|
|
933
|
+
options,
|
|
934
|
+
[...path, String(index)],
|
|
935
|
+
seen,
|
|
936
|
+
);
|
|
937
|
+
return nestedValue === undefined ? canonicalType("omitted") : nestedValue;
|
|
938
|
+
});
|
|
939
|
+
seen.delete(value);
|
|
940
|
+
return result;
|
|
908
941
|
}
|
|
909
942
|
|
|
910
943
|
if (typeof value === "object") {
|
|
944
|
+
const prototype = Object.getPrototypeOf(value);
|
|
945
|
+
if (prototype !== null && prototype !== Object.prototype) {
|
|
946
|
+
throw new IdempotencyFingerprintError(
|
|
947
|
+
"Cannot fingerprint unsupported object values. Use plain objects, arrays, Dates, and JSON primitives.",
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
if (Object.hasOwn(value, CANONICAL_TYPE_KEY)) {
|
|
951
|
+
throw new IdempotencyFingerprintError(
|
|
952
|
+
`Cannot fingerprint an object containing the reserved key "${CANONICAL_TYPE_KEY}".`,
|
|
953
|
+
);
|
|
954
|
+
}
|
|
911
955
|
if (seen.has(value)) {
|
|
912
956
|
throw new IdempotencyFingerprintError(
|
|
913
957
|
"Cannot fingerprint circular values.",
|
|
@@ -915,7 +959,7 @@ function canonicalize(
|
|
|
915
959
|
}
|
|
916
960
|
seen.add(value);
|
|
917
961
|
|
|
918
|
-
const result: Record<string, CanonicalValue> =
|
|
962
|
+
const result: Record<string, CanonicalValue> = Object.create(null);
|
|
919
963
|
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
920
964
|
const nestedValue = canonicalize(
|
|
921
965
|
(value as Record<string, unknown>)[key],
|
|
@@ -946,9 +990,13 @@ function bytesToHex(bytes: ArrayBuffer): string {
|
|
|
946
990
|
/**
|
|
947
991
|
* Create a SHA-256 fingerprint from a canonicalized value.
|
|
948
992
|
*
|
|
949
|
-
* Object keys are sorted,
|
|
950
|
-
*
|
|
951
|
-
*
|
|
993
|
+
* Object keys are sorted, while non-JSON values carry reserved type tags so
|
|
994
|
+
* BigInts and Dates cannot collide with same-looking strings. Ordinary JSON
|
|
995
|
+
* inputs retain their existing canonical representation. `undefined` values
|
|
996
|
+
* and functions are omitted from objects and represented as omitted array
|
|
997
|
+
* positions. Circular or unsupported object values, reserved tag keys,
|
|
998
|
+
* invalid dates, and non-finite numbers throw. Exact omit paths may be supplied
|
|
999
|
+
* as dotted strings or string arrays.
|
|
952
1000
|
*/
|
|
953
1001
|
export async function createIdempotencyFingerprint(
|
|
954
1002
|
value: unknown,
|
|
@@ -961,7 +1009,7 @@ export async function createIdempotencyFingerprint(
|
|
|
961
1009
|
}
|
|
962
1010
|
|
|
963
1011
|
const canonical = canonicalize(value, options, [], new WeakSet());
|
|
964
|
-
const json = JSON.stringify(canonical ??
|
|
1012
|
+
const json = JSON.stringify(canonical ?? canonicalType("omitted"));
|
|
965
1013
|
const digest = await globalThis.crypto.subtle.digest(
|
|
966
1014
|
"SHA-256",
|
|
967
1015
|
new TextEncoder().encode(json),
|
package/src/mail/index.ts
CHANGED
|
@@ -403,13 +403,17 @@ export function createMemoryMailerProvider(
|
|
|
403
403
|
onSend,
|
|
404
404
|
},
|
|
405
405
|
(delivery, durationMs) => {
|
|
406
|
+
const recipientCount =
|
|
407
|
+
delivery.message.to.length +
|
|
408
|
+
(delivery.message.cc?.length ?? 0) +
|
|
409
|
+
(delivery.message.bcc?.length ?? 0);
|
|
406
410
|
instrumentation.custom({
|
|
407
411
|
name: "mail.sent",
|
|
408
412
|
label: "Mail sent",
|
|
409
|
-
summary:
|
|
413
|
+
summary: "Mail sent",
|
|
410
414
|
details: {
|
|
411
|
-
|
|
412
|
-
|
|
415
|
+
provider: "memory",
|
|
416
|
+
recipientCount,
|
|
413
417
|
id: delivery.id,
|
|
414
418
|
durationMs,
|
|
415
419
|
},
|