@beignet/core 0.0.51 → 0.0.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +59 -19
- package/dist/application/index.d.ts +1 -1
- package/dist/application/index.d.ts.map +1 -1
- package/dist/application/index.js +5 -2
- package/dist/application/index.js.map +1 -1
- package/dist/events/index.d.ts +26 -2
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +84 -10
- package/dist/events/index.js.map +1 -1
- package/dist/events/payload-state.d.ts +14 -2
- package/dist/events/payload-state.d.ts.map +1 -1
- package/dist/events/payload-state.js +116 -4
- package/dist/events/payload-state.js.map +1 -1
- package/dist/events/transport.d.ts +25 -0
- package/dist/events/transport.d.ts.map +1 -0
- package/dist/events/transport.js +192 -0
- package/dist/events/transport.js.map +1 -0
- package/dist/openapi/index.d.ts.map +1 -1
- package/dist/openapi/index.js +27 -4
- package/dist/openapi/index.js.map +1 -1
- package/dist/outbox/index.d.ts.map +1 -1
- package/dist/outbox/index.js +10 -13
- package/dist/outbox/index.js.map +1 -1
- package/dist/ports/events.d.ts +4 -1
- package/dist/ports/events.d.ts.map +1 -1
- package/dist/ports/storage.d.ts +7 -0
- package/dist/ports/storage.d.ts.map +1 -1
- package/dist/ports/storage.js +4 -0
- package/dist/ports/storage.js.map +1 -1
- package/dist/ports/testing.d.ts +3 -2
- package/dist/ports/testing.d.ts.map +1 -1
- package/dist/ports/testing.js +7 -4
- package/dist/ports/testing.js.map +1 -1
- package/dist/ports/unit-of-work.d.ts +4 -4
- package/dist/ports/unit-of-work.d.ts.map +1 -1
- package/dist/ports/unit-of-work.js +8 -9
- package/dist/ports/unit-of-work.js.map +1 -1
- package/dist/search/index.js +2 -2
- package/dist/search/index.js.map +1 -1
- package/dist/server/instrumentation.d.ts +5 -5
- package/dist/server/instrumentation.d.ts.map +1 -1
- package/dist/server/instrumentation.js +3 -4
- package/dist/server/instrumentation.js.map +1 -1
- package/dist/server/response-finalization.d.ts.map +1 -1
- package/dist/server/response-finalization.js +25 -13
- package/dist/server/response-finalization.js.map +1 -1
- package/dist/server/server.d.ts +8 -5
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +1 -3
- package/dist/server/server.js.map +1 -1
- package/dist/uploads/index.d.ts.map +1 -1
- package/dist/uploads/index.js +37 -16
- package/dist/uploads/index.js.map +1 -1
- package/package.json +2 -2
- package/skills/app-architecture/SKILL.md +7 -5
- package/src/application/index.ts +24 -3
- package/src/events/index.ts +137 -10
- package/src/events/payload-state.ts +205 -6
- package/src/events/transport.ts +242 -0
- package/src/openapi/index.ts +41 -3
- package/src/outbox/index.ts +26 -18
- package/src/ports/events.ts +4 -1
- package/src/ports/storage.ts +10 -0
- package/src/ports/testing.ts +11 -4
- package/src/ports/unit-of-work.ts +20 -16
- package/src/search/index.ts +2 -2
- package/src/server/instrumentation.ts +10 -8
- package/src/server/response-finalization.ts +33 -15
- package/src/server/server.ts +9 -8
- package/src/uploads/index.ts +34 -16
package/src/events/index.ts
CHANGED
|
@@ -7,8 +7,21 @@ import {
|
|
|
7
7
|
} from "../tracing/index.js";
|
|
8
8
|
import {
|
|
9
9
|
isEventPayloadParsed,
|
|
10
|
-
|
|
10
|
+
isEventPayloadTransportStable,
|
|
11
|
+
markEventPayloadTransportStable,
|
|
11
12
|
} from "./payload-state.js";
|
|
13
|
+
import {
|
|
14
|
+
EventTransportError,
|
|
15
|
+
type EventTransportValue,
|
|
16
|
+
eventTransportValuesEqual,
|
|
17
|
+
toEventTransportValue,
|
|
18
|
+
} from "./transport.js";
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
EventTransportError,
|
|
22
|
+
type EventTransportErrorReason,
|
|
23
|
+
type EventTransportValue,
|
|
24
|
+
} from "./transport.js";
|
|
12
25
|
|
|
13
26
|
/**
|
|
14
27
|
* Any Standard Schema compatible validator.
|
|
@@ -299,6 +312,18 @@ export class EventValidationError extends Error {
|
|
|
299
312
|
}
|
|
300
313
|
}
|
|
301
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Parsed runtime value and canonical JSON value for one event publication.
|
|
317
|
+
*/
|
|
318
|
+
export interface PreparedEventPayload<E extends EventPayloadDef> {
|
|
319
|
+
/** Parsed Standard Schema output delivered to in-process listeners. */
|
|
320
|
+
readonly payload: InferEventPayload<E>;
|
|
321
|
+
/** Canonical JSON value written by serialized transports. */
|
|
322
|
+
readonly transportValue: EventTransportValue;
|
|
323
|
+
/** Complete publish metadata to forward to in-process subscribers. */
|
|
324
|
+
readonly publishOptions: EventPublishOptions;
|
|
325
|
+
}
|
|
326
|
+
|
|
302
327
|
function formatPath(path: StandardSchemaV1.Issue["path"]): string {
|
|
303
328
|
if (!path?.length) return "";
|
|
304
329
|
|
|
@@ -345,7 +370,9 @@ async function parsePayload<Schema extends StandardSchemaV1>(
|
|
|
345
370
|
* Define a typed event.
|
|
346
371
|
*
|
|
347
372
|
* Event payloads are validated before publishing through `publishEvent(...)`
|
|
348
|
-
* and before registered listeners run.
|
|
373
|
+
* and before registered listeners run. Producer helpers also require parsed
|
|
374
|
+
* output to be plain JSON that remains unchanged when validated again after a
|
|
375
|
+
* transport round trip.
|
|
349
376
|
*/
|
|
350
377
|
export function defineEvent<
|
|
351
378
|
Name extends string,
|
|
@@ -388,7 +415,101 @@ export async function parseEventPayload<E extends EventPayloadDef>(
|
|
|
388
415
|
}
|
|
389
416
|
|
|
390
417
|
/**
|
|
391
|
-
*
|
|
418
|
+
* Parse an event payload and prove that its canonical JSON survives transport.
|
|
419
|
+
*
|
|
420
|
+
* Custom event-bus providers must call this before publishing. The returned
|
|
421
|
+
* runtime payload is suitable for in-process listeners; `transportValue` is
|
|
422
|
+
* the exact JSON-safe value to encode for a serialized transport.
|
|
423
|
+
*/
|
|
424
|
+
export async function prepareEventPayloadForTransport<
|
|
425
|
+
E extends EventPayloadDef,
|
|
426
|
+
>(
|
|
427
|
+
event: E,
|
|
428
|
+
payload: unknown,
|
|
429
|
+
options?: EventPublishOptions,
|
|
430
|
+
): Promise<PreparedEventPayload<E>> {
|
|
431
|
+
const parsed = isEventPayloadParsed(event, payload, options)
|
|
432
|
+
? (payload as InferEventPayload<E>)
|
|
433
|
+
: await parseEventPayload(event, payload);
|
|
434
|
+
const transportValue = toEventTransportValue(event.name, parsed);
|
|
435
|
+
let canonicalPayload = parsed;
|
|
436
|
+
let transportStateIsCurrent = false;
|
|
437
|
+
|
|
438
|
+
try {
|
|
439
|
+
transportStateIsCurrent = isEventPayloadTransportStable(
|
|
440
|
+
event,
|
|
441
|
+
parsed,
|
|
442
|
+
transportValue,
|
|
443
|
+
options,
|
|
444
|
+
);
|
|
445
|
+
} catch {
|
|
446
|
+
// Revalidate if a mutated or proxied payload can no longer be compared.
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (!transportStateIsCurrent) {
|
|
450
|
+
let reparsed: InferEventPayload<E>;
|
|
451
|
+
try {
|
|
452
|
+
reparsed = await parseEventPayload(event, transportValue);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
throw new EventTransportError({
|
|
455
|
+
eventName: event.name,
|
|
456
|
+
reason: "not-stable",
|
|
457
|
+
message:
|
|
458
|
+
"is not transport-stable: its canonical JSON failed validation when decoded again. Event schemas must accept their own canonical JSON output.",
|
|
459
|
+
cause: error,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
let reparsedTransportValue: EventTransportValue;
|
|
464
|
+
try {
|
|
465
|
+
reparsedTransportValue = toEventTransportValue(event.name, reparsed);
|
|
466
|
+
} catch (error) {
|
|
467
|
+
throw new EventTransportError({
|
|
468
|
+
eventName: event.name,
|
|
469
|
+
reason: "not-stable",
|
|
470
|
+
path: error instanceof EventTransportError ? error.path : undefined,
|
|
471
|
+
message:
|
|
472
|
+
"is not transport-stable: validating its canonical JSON produced a value that is not JSON-safe.",
|
|
473
|
+
cause: error,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (!eventTransportValuesEqual(transportValue, reparsedTransportValue)) {
|
|
478
|
+
throw new EventTransportError({
|
|
479
|
+
eventName: event.name,
|
|
480
|
+
reason: "not-stable",
|
|
481
|
+
message:
|
|
482
|
+
"is not transport-stable: its canonical JSON changed when validated again. Event schema transforms must be idempotent.",
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
canonicalPayload = reparsed;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
return {
|
|
490
|
+
payload: canonicalPayload,
|
|
491
|
+
transportValue,
|
|
492
|
+
publishOptions: markEventPayloadTransportStable(
|
|
493
|
+
event,
|
|
494
|
+
canonicalPayload,
|
|
495
|
+
transportValue,
|
|
496
|
+
options,
|
|
497
|
+
),
|
|
498
|
+
};
|
|
499
|
+
} catch (error) {
|
|
500
|
+
throw new EventTransportError({
|
|
501
|
+
eventName: event.name,
|
|
502
|
+
reason: "not-stable",
|
|
503
|
+
message:
|
|
504
|
+
"is not transport-stable: its canonical output could not be inspected consistently.",
|
|
505
|
+
cause: error,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Validate an event payload, prove transport stability, and publish it through
|
|
512
|
+
* an event bus.
|
|
392
513
|
*/
|
|
393
514
|
export async function publishEvent<E extends EventPayloadDef>(
|
|
394
515
|
eventBus: EventBusLike,
|
|
@@ -396,8 +517,12 @@ export async function publishEvent<E extends EventPayloadDef>(
|
|
|
396
517
|
payload: InferEventPayload<E>,
|
|
397
518
|
options?: EventPublishOptions,
|
|
398
519
|
): Promise<void> {
|
|
399
|
-
const
|
|
400
|
-
|
|
520
|
+
const prepared = await prepareEventPayloadForTransport(
|
|
521
|
+
event,
|
|
522
|
+
payload,
|
|
523
|
+
options,
|
|
524
|
+
);
|
|
525
|
+
await eventBus.publish(event, prepared.payload, prepared.publishOptions);
|
|
401
526
|
}
|
|
402
527
|
|
|
403
528
|
function assertReadyTimeoutMs(value: number): void {
|
|
@@ -490,9 +615,11 @@ export function registerListeners<Ctx>(
|
|
|
490
615
|
listener.event,
|
|
491
616
|
async (rawPayload, publishOptions) => {
|
|
492
617
|
try {
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
618
|
+
const prepared = await prepareEventPayloadForTransport(
|
|
619
|
+
listener.event,
|
|
620
|
+
rawPayload,
|
|
621
|
+
publishOptions,
|
|
622
|
+
);
|
|
496
623
|
const traceAttributes = {
|
|
497
624
|
"beignet.listener.name": listener.name,
|
|
498
625
|
"beignet.event.name": listener.event.name,
|
|
@@ -504,14 +631,14 @@ export function registerListeners<Ctx>(
|
|
|
504
631
|
name: `beignet.listener ${listener.name}`,
|
|
505
632
|
type: "listener",
|
|
506
633
|
kind: "consumer",
|
|
507
|
-
parent: parseTraceCarrier(publishOptions
|
|
634
|
+
parent: parseTraceCarrier(prepared.publishOptions.trace),
|
|
508
635
|
attributes: traceAttributes,
|
|
509
636
|
metricAttributes: traceAttributes,
|
|
510
637
|
},
|
|
511
638
|
run: (ctx) =>
|
|
512
639
|
listener.handle({
|
|
513
640
|
event: listener.event,
|
|
514
|
-
payload,
|
|
641
|
+
payload: prepared.payload,
|
|
515
642
|
ctx,
|
|
516
643
|
}),
|
|
517
644
|
});
|
|
@@ -1,24 +1,223 @@
|
|
|
1
1
|
import type { EventPublishOptions } from "./index.js";
|
|
2
|
+
import type { EventTransportValue } from "./transport.js";
|
|
2
3
|
|
|
3
4
|
const PARSED_EVENT_PAYLOAD = Symbol.for("beignet.eventPayload.parsed");
|
|
5
|
+
const TRANSPORT_STABLE_EVENT_PAYLOAD = Symbol.for(
|
|
6
|
+
"beignet.eventPayload.transportStable",
|
|
7
|
+
);
|
|
4
8
|
|
|
5
9
|
type ParsedEventPublishOptions = EventPublishOptions & {
|
|
6
|
-
[PARSED_EVENT_PAYLOAD]?:
|
|
10
|
+
[PARSED_EVENT_PAYLOAD]?: object;
|
|
11
|
+
[TRANSPORT_STABLE_EVENT_PAYLOAD]?: object;
|
|
7
12
|
};
|
|
8
13
|
|
|
14
|
+
interface ParsedPayloadState {
|
|
15
|
+
readonly eventName: string;
|
|
16
|
+
readonly payloadSchema: object;
|
|
17
|
+
readonly validate: unknown;
|
|
18
|
+
readonly payload: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type PayloadSnapshot =
|
|
22
|
+
| { readonly kind: "primitive"; readonly value: unknown }
|
|
23
|
+
| {
|
|
24
|
+
readonly kind: "array";
|
|
25
|
+
readonly value: object;
|
|
26
|
+
readonly prototype: object | null;
|
|
27
|
+
readonly items: readonly PayloadSnapshot[];
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
readonly kind: "object";
|
|
31
|
+
readonly value: object;
|
|
32
|
+
readonly prototype: object | null;
|
|
33
|
+
readonly entries: readonly (readonly [string, PayloadSnapshot])[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
interface TransportStablePayloadState {
|
|
37
|
+
readonly eventName: string;
|
|
38
|
+
readonly payloadSchema: object;
|
|
39
|
+
readonly validate: unknown;
|
|
40
|
+
readonly payload: PayloadSnapshot;
|
|
41
|
+
readonly fingerprint: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface EventPayloadIdentity {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly payload: {
|
|
47
|
+
readonly "~standard": {
|
|
48
|
+
readonly validate: unknown;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const transportStablePayloadStates = new WeakMap<
|
|
54
|
+
object,
|
|
55
|
+
TransportStablePayloadState
|
|
56
|
+
>();
|
|
57
|
+
const parsedPayloadStates = new WeakMap<object, ParsedPayloadState>();
|
|
58
|
+
|
|
59
|
+
function transportFingerprint(value: EventTransportValue): string {
|
|
60
|
+
return JSON.stringify(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function capturePayloadSnapshot(value: unknown): PayloadSnapshot {
|
|
64
|
+
if (value === null || typeof value !== "object") {
|
|
65
|
+
return { kind: "primitive", value };
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(value)) {
|
|
68
|
+
const items: PayloadSnapshot[] = [];
|
|
69
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
70
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
71
|
+
if (!descriptor || !("value" in descriptor)) {
|
|
72
|
+
throw new TypeError(
|
|
73
|
+
"Transport-stable array state must contain data properties.",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
items.push(capturePayloadSnapshot(descriptor.value));
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
kind: "array",
|
|
80
|
+
value,
|
|
81
|
+
prototype: Object.getPrototypeOf(value),
|
|
82
|
+
items,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
kind: "object",
|
|
87
|
+
value,
|
|
88
|
+
prototype: Object.getPrototypeOf(value),
|
|
89
|
+
entries: Reflect.ownKeys(value).map((key) => {
|
|
90
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
91
|
+
if (typeof key !== "string" || !descriptor || !("value" in descriptor)) {
|
|
92
|
+
throw new TypeError(
|
|
93
|
+
"Transport-stable payload state must contain data properties.",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return [key, capturePayloadSnapshot(descriptor.value)] as const;
|
|
97
|
+
}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function matchesPayloadSnapshot(
|
|
102
|
+
snapshot: PayloadSnapshot,
|
|
103
|
+
value: unknown,
|
|
104
|
+
): boolean {
|
|
105
|
+
if (snapshot.kind === "primitive") return Object.is(snapshot.value, value);
|
|
106
|
+
if (snapshot.value !== value || value === null || typeof value !== "object") {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (Object.getPrototypeOf(value) !== snapshot.prototype) return false;
|
|
110
|
+
if (snapshot.kind === "array") {
|
|
111
|
+
return (
|
|
112
|
+
Array.isArray(value) &&
|
|
113
|
+
value.length === snapshot.items.length &&
|
|
114
|
+
snapshot.items.every((item, index) => {
|
|
115
|
+
const descriptor = Object.getOwnPropertyDescriptor(
|
|
116
|
+
value,
|
|
117
|
+
String(index),
|
|
118
|
+
);
|
|
119
|
+
return (
|
|
120
|
+
descriptor !== undefined &&
|
|
121
|
+
"value" in descriptor &&
|
|
122
|
+
matchesPayloadSnapshot(item, descriptor.value)
|
|
123
|
+
);
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (Array.isArray(value)) return false;
|
|
128
|
+
const keys = Reflect.ownKeys(value);
|
|
129
|
+
return (
|
|
130
|
+
keys.length === snapshot.entries.length &&
|
|
131
|
+
snapshot.entries.every(([key, item], index) => {
|
|
132
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
133
|
+
return (
|
|
134
|
+
keys[index] === key &&
|
|
135
|
+
descriptor !== undefined &&
|
|
136
|
+
"value" in descriptor &&
|
|
137
|
+
matchesPayloadSnapshot(item, descriptor.value)
|
|
138
|
+
);
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
9
143
|
export function markEventPayloadParsed(
|
|
144
|
+
event: EventPayloadIdentity,
|
|
145
|
+
payload: unknown,
|
|
10
146
|
options?: EventPublishOptions,
|
|
11
147
|
): EventPublishOptions {
|
|
148
|
+
const token = {};
|
|
149
|
+
parsedPayloadStates.set(token, {
|
|
150
|
+
eventName: event.name,
|
|
151
|
+
payloadSchema: event.payload,
|
|
152
|
+
validate: event.payload["~standard"].validate,
|
|
153
|
+
payload,
|
|
154
|
+
});
|
|
12
155
|
return {
|
|
13
156
|
...options,
|
|
14
|
-
[PARSED_EVENT_PAYLOAD]:
|
|
157
|
+
[PARSED_EVENT_PAYLOAD]: token,
|
|
15
158
|
} as ParsedEventPublishOptions;
|
|
16
159
|
}
|
|
17
160
|
|
|
18
|
-
export function isEventPayloadParsed(
|
|
161
|
+
export function isEventPayloadParsed(
|
|
162
|
+
event: EventPayloadIdentity,
|
|
163
|
+
payload: unknown,
|
|
164
|
+
options?: EventPublishOptions,
|
|
165
|
+
): boolean {
|
|
166
|
+
const token = (options as ParsedEventPublishOptions | undefined)?.[
|
|
167
|
+
PARSED_EVENT_PAYLOAD
|
|
168
|
+
];
|
|
169
|
+
const state = token ? parsedPayloadStates.get(token) : undefined;
|
|
170
|
+
return (
|
|
171
|
+
state?.eventName === event.name &&
|
|
172
|
+
state.payloadSchema === event.payload &&
|
|
173
|
+
state.validate === event.payload["~standard"].validate &&
|
|
174
|
+
Object.is(state.payload, payload)
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function markEventPayloadTransportStable(
|
|
179
|
+
event: EventPayloadIdentity,
|
|
180
|
+
payload: unknown,
|
|
181
|
+
transportValue: EventTransportValue,
|
|
182
|
+
options?: EventPublishOptions,
|
|
183
|
+
): EventPublishOptions {
|
|
184
|
+
const parsedToken = {};
|
|
185
|
+
parsedPayloadStates.set(parsedToken, {
|
|
186
|
+
eventName: event.name,
|
|
187
|
+
payloadSchema: event.payload,
|
|
188
|
+
validate: event.payload["~standard"].validate,
|
|
189
|
+
payload,
|
|
190
|
+
});
|
|
191
|
+
const stableToken = {};
|
|
192
|
+
transportStablePayloadStates.set(stableToken, {
|
|
193
|
+
eventName: event.name,
|
|
194
|
+
payloadSchema: event.payload,
|
|
195
|
+
validate: event.payload["~standard"].validate,
|
|
196
|
+
payload: capturePayloadSnapshot(payload),
|
|
197
|
+
fingerprint: transportFingerprint(transportValue),
|
|
198
|
+
});
|
|
199
|
+
return {
|
|
200
|
+
...options,
|
|
201
|
+
[PARSED_EVENT_PAYLOAD]: parsedToken,
|
|
202
|
+
[TRANSPORT_STABLE_EVENT_PAYLOAD]: stableToken,
|
|
203
|
+
} as ParsedEventPublishOptions;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function isEventPayloadTransportStable(
|
|
207
|
+
event: EventPayloadIdentity,
|
|
208
|
+
payload: unknown,
|
|
209
|
+
transportValue: EventTransportValue,
|
|
210
|
+
options?: EventPublishOptions,
|
|
211
|
+
): boolean {
|
|
212
|
+
const token = (options as ParsedEventPublishOptions | undefined)?.[
|
|
213
|
+
TRANSPORT_STABLE_EVENT_PAYLOAD
|
|
214
|
+
];
|
|
215
|
+
const state = token ? transportStablePayloadStates.get(token) : undefined;
|
|
19
216
|
return (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
217
|
+
state?.eventName === event.name &&
|
|
218
|
+
state.payloadSchema === event.payload &&
|
|
219
|
+
state.validate === event.payload["~standard"].validate &&
|
|
220
|
+
matchesPayloadSnapshot(state.payload, payload) &&
|
|
221
|
+
state.fingerprint === transportFingerprint(transportValue)
|
|
23
222
|
);
|
|
24
223
|
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/** Canonical JSON value that can cross an event transport boundary. */
|
|
2
|
+
export type EventTransportValue =
|
|
3
|
+
| null
|
|
4
|
+
| string
|
|
5
|
+
| number
|
|
6
|
+
| boolean
|
|
7
|
+
| readonly EventTransportValue[]
|
|
8
|
+
| { readonly [key: string]: EventTransportValue };
|
|
9
|
+
|
|
10
|
+
/** Why an event payload cannot safely cross a serialized transport. */
|
|
11
|
+
export type EventTransportErrorReason = "not-json-safe" | "not-stable";
|
|
12
|
+
|
|
13
|
+
/** Error thrown when an event payload cannot survive canonical JSON transport. */
|
|
14
|
+
export class EventTransportError extends Error {
|
|
15
|
+
/** Stable event name. */
|
|
16
|
+
readonly eventName: string;
|
|
17
|
+
/** Whether the value is not JSON-safe or changes under repeated parsing. */
|
|
18
|
+
readonly reason: EventTransportErrorReason;
|
|
19
|
+
/** Payload path associated with a JSON-safety failure. */
|
|
20
|
+
readonly path?: string;
|
|
21
|
+
|
|
22
|
+
constructor(args: {
|
|
23
|
+
eventName: string;
|
|
24
|
+
reason: EventTransportErrorReason;
|
|
25
|
+
message: string;
|
|
26
|
+
path?: string;
|
|
27
|
+
cause?: unknown;
|
|
28
|
+
}) {
|
|
29
|
+
super(`Event "${args.eventName}" payload ${args.message}`, {
|
|
30
|
+
cause: args.cause,
|
|
31
|
+
});
|
|
32
|
+
this.name = "EventTransportError";
|
|
33
|
+
this.eventName = args.eventName;
|
|
34
|
+
this.reason = args.reason;
|
|
35
|
+
this.path = args.path;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function childTransportPath(path: string, key: string): string {
|
|
40
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
|
|
41
|
+
? `${path}.${key}`
|
|
42
|
+
: `${path}[${JSON.stringify(key)}]`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function throwJsonSafetyError(args: {
|
|
46
|
+
eventName: string;
|
|
47
|
+
path: string;
|
|
48
|
+
message: string;
|
|
49
|
+
}): never {
|
|
50
|
+
throw new EventTransportError({
|
|
51
|
+
eventName: args.eventName,
|
|
52
|
+
reason: "not-json-safe",
|
|
53
|
+
path: args.path,
|
|
54
|
+
message: `is not transport-safe at ${args.path}: ${args.message}`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function toEventTransportValue(
|
|
59
|
+
eventName: string,
|
|
60
|
+
value: unknown,
|
|
61
|
+
path = "payload",
|
|
62
|
+
seen: WeakSet<object> = new WeakSet(),
|
|
63
|
+
): EventTransportValue {
|
|
64
|
+
if (value === null) return null;
|
|
65
|
+
|
|
66
|
+
if (typeof value === "string" || typeof value === "boolean") return value;
|
|
67
|
+
if (typeof value === "number") {
|
|
68
|
+
if (!Number.isFinite(value)) {
|
|
69
|
+
return throwJsonSafetyError({
|
|
70
|
+
eventName,
|
|
71
|
+
path,
|
|
72
|
+
message: "numbers must be finite.",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return Object.is(value, -0) ? 0 : value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (typeof value !== "object") {
|
|
79
|
+
return throwJsonSafetyError({
|
|
80
|
+
eventName,
|
|
81
|
+
path,
|
|
82
|
+
message: `received ${typeof value}; use null, strings, finite numbers, booleans, arrays, or plain objects.`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
if (value instanceof Date) {
|
|
88
|
+
return throwJsonSafetyError({
|
|
89
|
+
eventName,
|
|
90
|
+
path,
|
|
91
|
+
message: "Date values are not supported; use an ISO string or number.",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (seen.has(value)) {
|
|
96
|
+
return throwJsonSafetyError({
|
|
97
|
+
eventName,
|
|
98
|
+
path,
|
|
99
|
+
message: "circular references are not supported.",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
seen.add(value);
|
|
104
|
+
if (Array.isArray(value)) {
|
|
105
|
+
const output: EventTransportValue[] = [];
|
|
106
|
+
const ownKeys = Reflect.ownKeys(value);
|
|
107
|
+
const hasUnsupportedKey = ownKeys.some((key) => {
|
|
108
|
+
if (key === "length") return false;
|
|
109
|
+
if (typeof key !== "string") return true;
|
|
110
|
+
const index = Number(key);
|
|
111
|
+
return (
|
|
112
|
+
!Number.isInteger(index) ||
|
|
113
|
+
index < 0 ||
|
|
114
|
+
index >= value.length ||
|
|
115
|
+
String(index) !== key
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
if (hasUnsupportedKey) {
|
|
119
|
+
return throwJsonSafetyError({
|
|
120
|
+
eventName,
|
|
121
|
+
path,
|
|
122
|
+
message: "arrays cannot contain symbol or custom properties.",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (ownKeys.length !== value.length + 1) {
|
|
126
|
+
return throwJsonSafetyError({
|
|
127
|
+
eventName,
|
|
128
|
+
path,
|
|
129
|
+
message: "sparse arrays are not supported.",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
133
|
+
const key = String(index);
|
|
134
|
+
const itemPath = `${path}[${index}]`;
|
|
135
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
136
|
+
if (!descriptor) {
|
|
137
|
+
return throwJsonSafetyError({
|
|
138
|
+
eventName,
|
|
139
|
+
path: itemPath,
|
|
140
|
+
message: "sparse arrays are not supported.",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (!("value" in descriptor) || !descriptor.enumerable) {
|
|
144
|
+
return throwJsonSafetyError({
|
|
145
|
+
eventName,
|
|
146
|
+
path: itemPath,
|
|
147
|
+
message: "array items must be enumerable data properties.",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
output.push(
|
|
151
|
+
toEventTransportValue(eventName, descriptor.value, itemPath, seen),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return output;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const prototype = Object.getPrototypeOf(value);
|
|
158
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
159
|
+
return throwJsonSafetyError({
|
|
160
|
+
eventName,
|
|
161
|
+
path,
|
|
162
|
+
message: "objects must be plain objects.",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const output = {} as Record<string, EventTransportValue>;
|
|
167
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
168
|
+
if (typeof key !== "string") {
|
|
169
|
+
return throwJsonSafetyError({
|
|
170
|
+
eventName,
|
|
171
|
+
path,
|
|
172
|
+
message: "symbol properties are not supported.",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const propertyPath = childTransportPath(path, key);
|
|
176
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
177
|
+
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
|
178
|
+
return throwJsonSafetyError({
|
|
179
|
+
eventName,
|
|
180
|
+
path: propertyPath,
|
|
181
|
+
message: "properties must be enumerable data properties.",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
Object.defineProperty(output, key, {
|
|
185
|
+
configurable: true,
|
|
186
|
+
enumerable: true,
|
|
187
|
+
writable: true,
|
|
188
|
+
value: toEventTransportValue(
|
|
189
|
+
eventName,
|
|
190
|
+
descriptor.value,
|
|
191
|
+
propertyPath,
|
|
192
|
+
seen,
|
|
193
|
+
),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return output;
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error instanceof EventTransportError) throw error;
|
|
199
|
+
throw new EventTransportError({
|
|
200
|
+
eventName,
|
|
201
|
+
reason: "not-json-safe",
|
|
202
|
+
path,
|
|
203
|
+
message: `could not be inspected as transport data at ${path}.`,
|
|
204
|
+
cause: error,
|
|
205
|
+
});
|
|
206
|
+
} finally {
|
|
207
|
+
seen.delete(value);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function eventTransportValuesEqual(
|
|
212
|
+
left: EventTransportValue,
|
|
213
|
+
right: EventTransportValue,
|
|
214
|
+
): boolean {
|
|
215
|
+
if (left === right) return true;
|
|
216
|
+
if (left === null || right === null || typeof left !== typeof right) {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
220
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
|
221
|
+
return (
|
|
222
|
+
left.length === right.length &&
|
|
223
|
+
left.every((value, index) =>
|
|
224
|
+
eventTransportValuesEqual(value, right[index]),
|
|
225
|
+
)
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (typeof left !== "object" || typeof right !== "object") return false;
|
|
229
|
+
|
|
230
|
+
const leftRecord = left as Readonly<Record<string, EventTransportValue>>;
|
|
231
|
+
const rightRecord = right as Readonly<Record<string, EventTransportValue>>;
|
|
232
|
+
const leftKeys = Object.keys(leftRecord).sort();
|
|
233
|
+
const rightKeys = Object.keys(rightRecord).sort();
|
|
234
|
+
return (
|
|
235
|
+
leftKeys.length === rightKeys.length &&
|
|
236
|
+
leftKeys.every(
|
|
237
|
+
(key, index) =>
|
|
238
|
+
key === rightKeys[index] &&
|
|
239
|
+
eventTransportValuesEqual(leftRecord[key], rightRecord[key]),
|
|
240
|
+
)
|
|
241
|
+
);
|
|
242
|
+
}
|