export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } export function jsonValue(value: unknown): JsonValue { if (value === null || typeof value === 'boolean') return value if (typeof value === 'string') { assertValidUnicode(value) return value } if (typeof value === 'number') { if (!Number.isFinite(value)) throw new Error('canonical JSON does not permit non-finite numbers') return value } if (Array.isArray(value)) return value.map(jsonValue) if (typeof value === 'object') { const prototype = Object.getPrototypeOf(value) if (prototype !== Object.prototype && prototype !== null) throw new Error('value is not JSON') return Object.fromEntries( Object.entries(value).map(([key, member]) => { assertValidUnicode(key) return [key, jsonValue(member)] }), ) } throw new Error('value is not JSON') } export function canonicalJson(value: JsonValue): string { if ( value === null || typeof value === 'boolean' || typeof value === 'string' ) { if (typeof value === 'string') assertValidUnicode(value) return JSON.stringify(value) } if (typeof value === 'number') { if (!Number.isFinite(value)) throw new Error('canonical JSON does not permit non-finite numbers') return JSON.stringify(value) } if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` return `{${Object.keys(value) .map((key) => { assertValidUnicode(key) return key }) .sort() .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) .join(',')}}` } function assertValidUnicode(value: string): void { for (let index = 0; index < value.length; index += 1) { const codeUnit = value.charCodeAt(index) if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { const next = value.charCodeAt(index + 1) if (index + 1 >= value.length || next < 0xdc00 || next > 0xdfff) throw new Error('canonical JSON does not permit lone surrogates') index += 1 } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { throw new Error('canonical JSON does not permit lone surrogates') } } } export async function requestFingerprint(value: JsonValue): Promise { const digest = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(canonicalJson(value)), ) return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0'), ).join('') }