@beignet/core 0.0.51 → 0.0.53
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 +27 -0
- package/README.md +112 -20
- 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/encryption/index.d.ts +40 -0
- package/dist/encryption/index.d.ts.map +1 -0
- package/dist/encryption/index.js +134 -0
- package/dist/encryption/index.js.map +1 -0
- 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/request-executor.d.ts.map +1 -1
- package/dist/server/request-executor.js +13 -0
- package/dist/server/request-executor.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/server/use-case-route.d.ts +82 -17
- package/dist/server/use-case-route.d.ts.map +1 -1
- package/dist/server/use-case-route.js +40 -4
- package/dist/server/use-case-route.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 +6 -2
- package/skills/app-architecture/SKILL.md +29 -6
- package/src/application/index.ts +24 -3
- package/src/encryption/index.ts +198 -0
- 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/request-executor.ts +19 -0
- package/src/server/response-finalization.ts +33 -15
- package/src/server/server.ts +9 -8
- package/src/server/use-case-route.ts +234 -27
- package/src/uploads/index.ts +34 -16
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import "../server-only.js";
|
|
2
|
+
|
|
3
|
+
/** Non-secret, authenticated metadata. Supply the same expected context on reads. */
|
|
4
|
+
export type EncryptionContext = Readonly<Record<string, string>>;
|
|
5
|
+
|
|
6
|
+
/** Options shared by string encryption and decryption. */
|
|
7
|
+
export interface EncryptionValueOptions {
|
|
8
|
+
/** Plaintext for encrypt; the complete encrypted value for decrypt. */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Bind the value to its purpose, tenant, or record. This is not authorization. */
|
|
11
|
+
context?: EncryptionContext;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Server-side authenticated string encryption. */
|
|
15
|
+
export interface EncryptionPort {
|
|
16
|
+
/** Encrypt a string; persist the complete returned value. */
|
|
17
|
+
encrypt(options: EncryptionValueOptions): Promise<string>;
|
|
18
|
+
/** Authenticate and decrypt a value with the same expected context. */
|
|
19
|
+
decrypt(options: EncryptionValueOptions): Promise<string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Configuration for the built-in AES-256-GCM implementation. */
|
|
23
|
+
export interface CreateEncryptionOptions {
|
|
24
|
+
/** A base64: prefixed, canonical Base64 encoding of 32 random bytes. */
|
|
25
|
+
key: string;
|
|
26
|
+
/** Decryption-only keys in the same format. New writes always use key. */
|
|
27
|
+
previousKeys?: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Malformed, unauthenticated, or undecryptable ciphertext. Contains no input. */
|
|
31
|
+
export class EncryptionDecryptionError extends Error {
|
|
32
|
+
/** Stable error name, independent of the decryption failure's cause. */
|
|
33
|
+
readonly name = "EncryptionDecryptionError";
|
|
34
|
+
|
|
35
|
+
constructor() {
|
|
36
|
+
super("Unable to decrypt encrypted value.");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const envelopePrefix = "beignet:enc:v1:";
|
|
41
|
+
const ivLength = 12;
|
|
42
|
+
const tagLength = 16;
|
|
43
|
+
const encoder = new TextEncoder();
|
|
44
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
45
|
+
|
|
46
|
+
function encodeBase64(bytes: Uint8Array): string {
|
|
47
|
+
// Bound each spread so large values do not overflow the argument stack.
|
|
48
|
+
const chunks: string[] = [];
|
|
49
|
+
for (let offset = 0; offset < bytes.length; offset += 8192) {
|
|
50
|
+
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + 8192)));
|
|
51
|
+
}
|
|
52
|
+
return btoa(chunks.join(""));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function decodeBase64(value: string): Uint8Array<ArrayBuffer> {
|
|
56
|
+
if (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
|
|
57
|
+
throw new Error("Invalid Base64 encoding.");
|
|
58
|
+
}
|
|
59
|
+
const bytes = Uint8Array.from(atob(value), (character) =>
|
|
60
|
+
character.charCodeAt(0),
|
|
61
|
+
);
|
|
62
|
+
if (encodeBase64(bytes) !== value)
|
|
63
|
+
throw new Error("Noncanonical Base64 encoding.");
|
|
64
|
+
return bytes;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function decodeKey(value: string, label: string): Uint8Array<ArrayBuffer> {
|
|
68
|
+
try {
|
|
69
|
+
if (
|
|
70
|
+
typeof value !== "string" ||
|
|
71
|
+
value.length !== 51 ||
|
|
72
|
+
!value.startsWith("base64:")
|
|
73
|
+
)
|
|
74
|
+
throw new Error();
|
|
75
|
+
const bytes = decodeBase64(value.slice(7));
|
|
76
|
+
if (bytes.length !== 32) throw new Error();
|
|
77
|
+
return bytes;
|
|
78
|
+
} catch {
|
|
79
|
+
throw new TypeError(
|
|
80
|
+
`${label} must be base64: followed by the Base64 encoding of 32 bytes. Use generateEncryptionKey().`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function authenticatedData(
|
|
86
|
+
context: EncryptionContext | undefined,
|
|
87
|
+
): Uint8Array<ArrayBuffer> {
|
|
88
|
+
if (
|
|
89
|
+
context !== undefined &&
|
|
90
|
+
(context === null ||
|
|
91
|
+
typeof context !== "object" ||
|
|
92
|
+
(Object.getPrototypeOf(context) !== Object.prototype &&
|
|
93
|
+
Object.getPrototypeOf(context) !== null))
|
|
94
|
+
) {
|
|
95
|
+
throw new TypeError(
|
|
96
|
+
"Encryption context must be a plain record of strings.",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const entries = Object.entries(context ?? {}).sort(([left], [right]) =>
|
|
100
|
+
left < right ? -1 : left > right ? 1 : 0,
|
|
101
|
+
);
|
|
102
|
+
if (entries.some(([, value]) => typeof value !== "string")) {
|
|
103
|
+
throw new TypeError("Encryption context values must be strings.");
|
|
104
|
+
}
|
|
105
|
+
return encoder.encode(JSON.stringify([envelopePrefix, entries]));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Generate a new 256-bit key. Store it in a server secret store; never log it. */
|
|
109
|
+
export function generateEncryptionKey(): string {
|
|
110
|
+
return `base64:${encodeBase64(crypto.getRandomValues(new Uint8Array(32)))}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Create authenticated string encryption using Web Crypto AES-256-GCM.
|
|
115
|
+
* Configuration is validated synchronously. Each write uses a random 96-bit IV
|
|
116
|
+
* and a 128-bit authentication tag. Previous keys are used only for reads.
|
|
117
|
+
* No environment variables are read and no plaintext, keys, or context are logged.
|
|
118
|
+
*/
|
|
119
|
+
export function createEncryption(
|
|
120
|
+
options: CreateEncryptionOptions,
|
|
121
|
+
): EncryptionPort {
|
|
122
|
+
const current = decodeKey(options?.key, "Encryption key");
|
|
123
|
+
if (
|
|
124
|
+
options.previousKeys !== undefined &&
|
|
125
|
+
!Array.isArray(options.previousKeys)
|
|
126
|
+
) {
|
|
127
|
+
throw new TypeError("Encryption previousKeys must be an array of keys.");
|
|
128
|
+
}
|
|
129
|
+
const rawKeys = [
|
|
130
|
+
current,
|
|
131
|
+
...Array.from(options.previousKeys ?? [], (key, index) =>
|
|
132
|
+
decodeKey(key, `Encryption previousKeys[${index}]`),
|
|
133
|
+
),
|
|
134
|
+
];
|
|
135
|
+
const importedKeys: Array<Promise<CryptoKey> | undefined> = [];
|
|
136
|
+
const getKey = (index: number): Promise<CryptoKey> => {
|
|
137
|
+
const existing = importedKeys[index];
|
|
138
|
+
if (existing) return existing;
|
|
139
|
+
const imported = crypto.subtle.importKey(
|
|
140
|
+
"raw",
|
|
141
|
+
rawKeys[index],
|
|
142
|
+
"AES-GCM",
|
|
143
|
+
false,
|
|
144
|
+
["encrypt", "decrypt"],
|
|
145
|
+
);
|
|
146
|
+
importedKeys[index] = imported;
|
|
147
|
+
return imported;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
async encrypt({ value, context }) {
|
|
152
|
+
if (typeof value !== "string")
|
|
153
|
+
throw new TypeError("Encryption value must be a string.");
|
|
154
|
+
const additionalData = authenticatedData(context);
|
|
155
|
+
const iv = crypto.getRandomValues(new Uint8Array(ivLength));
|
|
156
|
+
const ciphertext = new Uint8Array(
|
|
157
|
+
await crypto.subtle.encrypt(
|
|
158
|
+
{ name: "AES-GCM", iv, additionalData, tagLength: tagLength * 8 },
|
|
159
|
+
await getKey(0),
|
|
160
|
+
// JSON preserves all JavaScript strings, including lone UTF-16 surrogates.
|
|
161
|
+
encoder.encode(JSON.stringify(value)),
|
|
162
|
+
),
|
|
163
|
+
);
|
|
164
|
+
const envelope = new Uint8Array(iv.length + ciphertext.length);
|
|
165
|
+
envelope.set(iv);
|
|
166
|
+
envelope.set(ciphertext, iv.length);
|
|
167
|
+
return `${envelopePrefix}${encodeBase64(envelope)}`;
|
|
168
|
+
},
|
|
169
|
+
async decrypt(options) {
|
|
170
|
+
try {
|
|
171
|
+
const { value, context } = options;
|
|
172
|
+
if (typeof value !== "string" || !value.startsWith(envelopePrefix))
|
|
173
|
+
throw new Error();
|
|
174
|
+
const envelope = decodeBase64(value.slice(envelopePrefix.length));
|
|
175
|
+
if (envelope.length < ivLength + tagLength + 2) throw new Error();
|
|
176
|
+
const additionalData = authenticatedData(context);
|
|
177
|
+
const iv = envelope.slice(0, ivLength);
|
|
178
|
+
const ciphertext = envelope.slice(ivLength);
|
|
179
|
+
for (let index = 0; index < rawKeys.length; index++) {
|
|
180
|
+
try {
|
|
181
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
182
|
+
{ name: "AES-GCM", iv, additionalData, tagLength: tagLength * 8 },
|
|
183
|
+
await getKey(index),
|
|
184
|
+
ciphertext,
|
|
185
|
+
);
|
|
186
|
+
const parsed: unknown = JSON.parse(decoder.decode(plaintext));
|
|
187
|
+
if (typeof parsed === "string") return parsed;
|
|
188
|
+
} catch {
|
|
189
|
+
// Try only the configured key ring; never return unauthenticated data.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// All decryption failures share one non-sensitive public error.
|
|
194
|
+
}
|
|
195
|
+
throw new EncryptionDecryptionError();
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
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
|
}
|