mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-07 19:06:11 +00:00
190 lines
5.6 KiB
TypeScript
190 lines
5.6 KiB
TypeScript
/**
|
|
* Deterministic JSON utilities for consistent object serialization and hashing
|
|
*
|
|
* Provides stable JSON stringification with sorted keys and consistent handling
|
|
* of edge cases like undefined, circular references, and special types.
|
|
*
|
|
* Used for:
|
|
* - Consistent encryption/decryption in API communication
|
|
* - Reliable message deduplication in session scanning
|
|
* - Stable object comparison for optimistic concurrency
|
|
*/
|
|
|
|
import { createHash } from 'crypto';
|
|
|
|
/**
|
|
* Options for deterministic JSON stringification
|
|
*/
|
|
export interface DeterministicJsonOptions {
|
|
/** How to handle undefined values */
|
|
undefinedBehavior?: 'omit' | 'null' | 'throw';
|
|
/** Whether to sort array contents (default: false) */
|
|
sortArrays?: boolean;
|
|
/** Custom replacer function */
|
|
replacer?: (key: string, value: any) => any;
|
|
/** Whether to include Symbol properties (default: false) */
|
|
includeSymbols?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Deterministically stringify a JSON object with sorted keys
|
|
*
|
|
* @param obj Object to stringify
|
|
* @param options Stringification options
|
|
* @returns Deterministic JSON string
|
|
*/
|
|
export function deterministicStringify(
|
|
obj: any,
|
|
options: DeterministicJsonOptions = {}
|
|
): string {
|
|
const {
|
|
undefinedBehavior = 'omit',
|
|
sortArrays = false,
|
|
replacer,
|
|
includeSymbols = false
|
|
} = options;
|
|
|
|
const seen = new WeakSet();
|
|
|
|
function processValue(value: any, key?: string): any {
|
|
// Handle replacer function
|
|
if (replacer && key !== undefined) {
|
|
value = replacer(key, value);
|
|
}
|
|
|
|
// Handle primitive types
|
|
if (value === null) return null;
|
|
if (value === undefined) {
|
|
switch (undefinedBehavior) {
|
|
case 'omit': return undefined;
|
|
case 'null': return null;
|
|
case 'throw': throw new Error(`Undefined value at key: ${key}`);
|
|
}
|
|
}
|
|
if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
|
|
return value;
|
|
}
|
|
|
|
// Handle special types
|
|
if (value instanceof Date) {
|
|
return value.toISOString();
|
|
}
|
|
if (value instanceof RegExp) {
|
|
return value.toString();
|
|
}
|
|
if (typeof value === 'function') {
|
|
return undefined; // Functions are omitted
|
|
}
|
|
if (typeof value === 'symbol') {
|
|
return includeSymbols ? value.toString() : undefined;
|
|
}
|
|
if (typeof value === 'bigint') {
|
|
return value.toString() + 'n';
|
|
}
|
|
|
|
// Handle circular references
|
|
if (seen.has(value)) {
|
|
throw new Error('Circular reference detected');
|
|
}
|
|
seen.add(value);
|
|
|
|
// Handle arrays
|
|
if (Array.isArray(value)) {
|
|
const processed = value.map((item, index) => processValue(item, String(index)))
|
|
.filter(item => item !== undefined);
|
|
|
|
if (sortArrays) {
|
|
// Sort arrays by their stringified content for true determinism
|
|
processed.sort((a, b) => {
|
|
const aStr = JSON.stringify(processValue(a));
|
|
const bStr = JSON.stringify(processValue(b));
|
|
return aStr.localeCompare(bStr);
|
|
});
|
|
}
|
|
|
|
seen.delete(value);
|
|
return processed;
|
|
}
|
|
|
|
// Handle objects
|
|
if (value.constructor === Object || value.constructor === undefined) {
|
|
const processed: Record<string, any> = {};
|
|
const keys = Object.keys(value).sort();
|
|
|
|
for (const k of keys) {
|
|
const processedValue = processValue(value[k], k);
|
|
if (processedValue !== undefined) {
|
|
processed[k] = processedValue;
|
|
}
|
|
}
|
|
|
|
seen.delete(value);
|
|
return processed;
|
|
}
|
|
|
|
// Handle other object types (like class instances)
|
|
// Try to convert to plain object
|
|
try {
|
|
const plain = { ...value };
|
|
seen.delete(value);
|
|
return processValue(plain, key);
|
|
} catch {
|
|
seen.delete(value);
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
const processed = processValue(obj);
|
|
return JSON.stringify(processed);
|
|
}
|
|
|
|
/**
|
|
* Calculate SHA-256 hash of an object using deterministic JSON stringification
|
|
*
|
|
* @param obj Object to hash
|
|
* @param options Stringification options
|
|
* @param encoding Output encoding (default: 'hex')
|
|
* @returns Hash string
|
|
*/
|
|
export function hashObject(
|
|
obj: any,
|
|
options?: DeterministicJsonOptions,
|
|
encoding: 'hex' | 'base64' | 'base64url' = 'hex'
|
|
): string {
|
|
const jsonString = deterministicStringify(obj, options);
|
|
return createHash('sha256').update(jsonString).digest(encoding);
|
|
}
|
|
|
|
/**
|
|
* Compare two objects for deep equality using deterministic stringification
|
|
*
|
|
* @param a First object
|
|
* @param b Second object
|
|
* @param options Stringification options
|
|
* @returns True if objects are deeply equal
|
|
*/
|
|
export function deepEqual(
|
|
a: any,
|
|
b: any,
|
|
options?: DeterministicJsonOptions
|
|
): boolean {
|
|
try {
|
|
return deterministicStringify(a, options) === deterministicStringify(b, options);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a stable hash key for an object suitable for use as a Map key
|
|
*
|
|
* @param obj Object to create key for
|
|
* @param options Stringification options
|
|
* @returns Stable string key
|
|
*/
|
|
export function objectKey(
|
|
obj: any,
|
|
options?: DeterministicJsonOptions
|
|
): string {
|
|
return hashObject(obj, options, 'base64url');
|
|
} |