@beignet/core
Version:
Core framework primitives for Beignet
243 lines (227 loc) • 7.13 kB
text/typescript
/** Canonical JSON value that can cross an event transport boundary. */
export type EventTransportValue =
| null
| string
| number
| boolean
| readonly EventTransportValue[]
| { readonly [key: string]: EventTransportValue };
/** Why an event payload cannot safely cross a serialized transport. */
export type EventTransportErrorReason = "not-json-safe" | "not-stable";
/** Error thrown when an event payload cannot survive canonical JSON transport. */
export class EventTransportError extends Error {
/** Stable event name. */
readonly eventName: string;
/** Whether the value is not JSON-safe or changes under repeated parsing. */
readonly reason: EventTransportErrorReason;
/** Payload path associated with a JSON-safety failure. */
readonly path?: string;
constructor(args: {
eventName: string;
reason: EventTransportErrorReason;
message: string;
path?: string;
cause?: unknown;
}) {
super(`Event "${args.eventName}" payload ${args.message}`, {
cause: args.cause,
});
this.name = "EventTransportError";
this.eventName = args.eventName;
this.reason = args.reason;
this.path = args.path;
}
}
function childTransportPath(path: string, key: string): string {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
? `${path}.${key}`
: `${path}[${JSON.stringify(key)}]`;
}
function throwJsonSafetyError(args: {
eventName: string;
path: string;
message: string;
}): never {
throw new EventTransportError({
eventName: args.eventName,
reason: "not-json-safe",
path: args.path,
message: `is not transport-safe at ${args.path}: ${args.message}`,
});
}
export function toEventTransportValue(
eventName: string,
value: unknown,
path = "payload",
seen: WeakSet<object> = new WeakSet(),
): EventTransportValue {
if (value === null) return null;
if (typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "number") {
if (!Number.isFinite(value)) {
return throwJsonSafetyError({
eventName,
path,
message: "numbers must be finite.",
});
}
return Object.is(value, -0) ? 0 : value;
}
if (typeof value !== "object") {
return throwJsonSafetyError({
eventName,
path,
message: `received ${typeof value}; use null, strings, finite numbers, booleans, arrays, or plain objects.`,
});
}
try {
if (value instanceof Date) {
return throwJsonSafetyError({
eventName,
path,
message: "Date values are not supported; use an ISO string or number.",
});
}
if (seen.has(value)) {
return throwJsonSafetyError({
eventName,
path,
message: "circular references are not supported.",
});
}
seen.add(value);
if (Array.isArray(value)) {
const output: EventTransportValue[] = [];
const ownKeys = Reflect.ownKeys(value);
const hasUnsupportedKey = ownKeys.some((key) => {
if (key === "length") return false;
if (typeof key !== "string") return true;
const index = Number(key);
return (
!Number.isInteger(index) ||
index < 0 ||
index >= value.length ||
String(index) !== key
);
});
if (hasUnsupportedKey) {
return throwJsonSafetyError({
eventName,
path,
message: "arrays cannot contain symbol or custom properties.",
});
}
if (ownKeys.length !== value.length + 1) {
return throwJsonSafetyError({
eventName,
path,
message: "sparse arrays are not supported.",
});
}
for (let index = 0; index < value.length; index += 1) {
const key = String(index);
const itemPath = `${path}[${index}]`;
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor) {
return throwJsonSafetyError({
eventName,
path: itemPath,
message: "sparse arrays are not supported.",
});
}
if (!("value" in descriptor) || !descriptor.enumerable) {
return throwJsonSafetyError({
eventName,
path: itemPath,
message: "array items must be enumerable data properties.",
});
}
output.push(
toEventTransportValue(eventName, descriptor.value, itemPath, seen),
);
}
return output;
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
return throwJsonSafetyError({
eventName,
path,
message: "objects must be plain objects.",
});
}
const output = {} as Record<string, EventTransportValue>;
for (const key of Reflect.ownKeys(value)) {
if (typeof key !== "string") {
return throwJsonSafetyError({
eventName,
path,
message: "symbol properties are not supported.",
});
}
const propertyPath = childTransportPath(path, key);
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
return throwJsonSafetyError({
eventName,
path: propertyPath,
message: "properties must be enumerable data properties.",
});
}
Object.defineProperty(output, key, {
configurable: true,
enumerable: true,
writable: true,
value: toEventTransportValue(
eventName,
descriptor.value,
propertyPath,
seen,
),
});
}
return output;
} catch (error) {
if (error instanceof EventTransportError) throw error;
throw new EventTransportError({
eventName,
reason: "not-json-safe",
path,
message: `could not be inspected as transport data at ${path}.`,
cause: error,
});
} finally {
seen.delete(value);
}
}
export function eventTransportValuesEqual(
left: EventTransportValue,
right: EventTransportValue,
): boolean {
if (left === right) return true;
if (left === null || right === null || typeof left !== typeof right) {
return false;
}
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right)) return false;
return (
left.length === right.length &&
left.every((value, index) =>
eventTransportValuesEqual(value, right[index]),
)
);
}
if (typeof left !== "object" || typeof right !== "object") return false;
const leftRecord = left as Readonly<Record<string, EventTransportValue>>;
const rightRecord = right as Readonly<Record<string, EventTransportValue>>;
const leftKeys = Object.keys(leftRecord).sort();
const rightKeys = Object.keys(rightRecord).sort();
return (
leftKeys.length === rightKeys.length &&
leftKeys.every(
(key, index) =>
key === rightKeys[index] &&
eventTransportValuesEqual(leftRecord[key], rightRecord[key]),
)
);
}