import { createHash } from "node:crypto"; type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; export function canonicalize(value: unknown): string { return renderCanonical(assertJsonValue(value)); } export function hash(value: unknown): string { return createHash("sha256").update(canonicalize(value)).digest("hex"); } function renderCanonical(value: JsonValue): string { if (value === null || typeof value === "boolean" || typeof value === "string") { return JSON.stringify(value); } if (typeof value === "number") { return renderCanonicalNumber(value); } if (Array.isArray(value)) { return `[${value.map((item) => renderCanonical(item)).join(",")}]`; } return `{${Object.keys(value) .sort() .map((key) => `${JSON.stringify(key)}:${renderCanonical(value[key] as JsonValue)}`) .join(",")}}`; } function assertJsonValue(value: unknown): JsonValue { if ( value === null || typeof value === "boolean" || typeof value === "string" || Array.isArray(value) ) { if (Array.isArray(value)) { assertOnlyArrayIndexKeys(value); const arrayValue: JsonValue[] = []; for (let index = 0; index < value.length; index += 1) { if (!(index in value)) { throw new TypeError(`Cannot canonicalize sparse array at index ${index}`); } arrayValue.push(assertJsonValue(value[index])); } return arrayValue; } return value; } if (typeof value === "number") { if (!Number.isFinite(value)) { throw new TypeError("Cannot canonicalize non-finite numbers"); } return value; } if (typeof value === "object") { const prototype = Object.getPrototypeOf(value); if (prototype !== Object.prototype && prototype !== null) { throw new TypeError("Cannot canonicalize non-plain object"); } assertOnlyEnumerableStringKeys(value); const objectValue: Record = Object.create(null) as Record; for (const [key, childValue] of Object.entries(value as Record)) { if (childValue === undefined) { throw new TypeError(`Cannot canonicalize undefined at key ${key}`); } objectValue[key] = assertJsonValue(childValue); } return objectValue; } throw new TypeError(`Cannot canonicalize ${typeof value}`); } function assertOnlyEnumerableStringKeys(value: object) { const enumerableStringKeys = Object.keys(value); const ownKeys = Reflect.ownKeys(value); const hasOnlyEnumerableStringKeys = ownKeys.length === enumerableStringKeys.length && ownKeys.every( (key) => typeof key === "string" && Object.prototype.propertyIsEnumerable.call(value, key), ); if (!hasOnlyEnumerableStringKeys) { throw new TypeError("Cannot canonicalize non-enumerable or symbol object keys"); } } function assertOnlyArrayIndexKeys(value: unknown[]) { for (const key of Reflect.ownKeys(value)) { if (key === "length") { continue; } if (typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key)) { throw new TypeError("Cannot canonicalize non-index array object keys"); } const index = Number(key); if (!Number.isInteger(index) || index < 0 || index >= value.length || String(index) !== key) { throw new TypeError("Cannot canonicalize non-index array object keys"); } } } function renderCanonicalNumber(value: number): string { if (Object.is(value, -0) || value === 0) { return "0"; } const candidates = new Set(); addNumberCandidate(candidates, value, value.toString()); const jsonCandidate = JSON.stringify(value); if (jsonCandidate !== undefined) { addNumberCandidate(candidates, value, jsonCandidate); } for (let precision = 1; precision <= 17; precision += 1) { addNumberCandidate(candidates, value, value.toPrecision(precision)); addNumberCandidate(candidates, value, value.toExponential(precision - 1)); } const [best] = [...candidates].sort( (left, right) => left.length - right.length || compareCodeUnits(left, right), ); if (!best) { throw new TypeError(`Cannot canonicalize number ${value}`); } return best; } function addNumberCandidate(candidates: Set, value: number, raw: string) { const candidate = normalizeNumberLiteral(raw); for (const equivalent of expandNumberLiteral(candidate)) { if (Number(equivalent) === value) { candidates.add(equivalent); } } } function compareCodeUnits(left: string, right: string) { if (left < right) { return -1; } if (left > right) { return 1; } return 0; } function normalizeNumberLiteral(raw: string): string { const [mantissaText, exponentText] = raw.toLowerCase().split("e"); const mantissa = normalizeDecimal(mantissaText ?? ""); if (exponentText === undefined) { return mantissa; } const exponent = normalizeExponent(exponentText); if (exponent === "0") { return mantissa; } return `${mantissa}e${exponent}`; } function normalizeDecimal(raw: string): string { if (!raw.includes(".")) { return raw; } const trimmed = raw.replace(/0+$/, "").replace(/\.$/, ""); if (trimmed === "-0") { return "0"; } return trimmed; } function normalizeExponent(raw: string): string { const sign = raw.startsWith("-") ? "-" : ""; const unsigned = raw.replace(/^[+-]/, "").replace(/^0+/, ""); if (unsigned === "") { return "0"; } return `${sign}${unsigned}`; } function expandNumberLiteral(raw: string): string[] { const parsed = parseNumberLiteral(raw); if (!parsed) { return [raw]; } const plain = renderPlainDecimal(parsed); const candidates = new Set([plain]); for (let integerDigits = 1; integerDigits <= parsed.digits.length; integerDigits += 1) { const exponent = parsed.power + parsed.digits.length - integerDigits; if (exponent === 0) { continue; } const mantissa = integerDigits === parsed.digits.length ? parsed.digits : `${parsed.digits.slice(0, integerDigits)}.${parsed.digits.slice(integerDigits)}`; candidates.add(`${parsed.sign}${mantissa}e${exponent}`); } return [...candidates]; } function parseNumberLiteral(raw: string) { const match = raw.match(/^(-?)(\d+)(?:\.(\d+))?(?:e(-?\d+))?$/); if (!match) { return undefined; } const [, sign, integerPart, fractionalPart = "", exponentPart] = match; let digits = `${integerPart}${fractionalPart}`; let power = (exponentPart === undefined ? 0 : Number(exponentPart)) - fractionalPart.length; digits = digits.replace(/^0+/, ""); if (digits === "") { return { sign: "", digits: "0", power: 0 }; } const lengthBeforeTrailingTrim = digits.length; digits = digits.replace(/0+$/, ""); power += lengthBeforeTrailingTrim - digits.length; return { sign: sign ?? "", digits, power }; } function renderPlainDecimal(parsed: { sign: string; digits: string; power: number }) { if (parsed.digits === "0") { return "0"; } if (parsed.power >= 0) { return `${parsed.sign}${parsed.digits}${"0".repeat(parsed.power)}`; } const pointIndex = parsed.digits.length + parsed.power; if (pointIndex > 0) { return `${parsed.sign}${parsed.digits.slice(0, pointIndex)}.${parsed.digits.slice(pointIndex)}`; } return `${parsed.sign}0.${"0".repeat(-pointIndex)}${parsed.digits}`; }