UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

243 lines (227 loc) 7.13 kB
/** 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]), ) ); }