|
| 1 | +/** |
| 2 | + * Crypto utilities for the SDK. |
| 3 | + */ |
| 4 | + |
| 5 | +import { NetworkError, ValidationError } from "../core/errors.js"; |
| 6 | + |
| 7 | +/** |
| 8 | + * Deterministically stringifies an object by sorting keys recursively. |
| 9 | + * Handles deeply nested objects and null values correctly. |
| 10 | + */ |
| 11 | +export function stableStringify(obj: unknown): string | undefined { |
| 12 | + if (obj === undefined || typeof obj === "function" || typeof obj === "symbol") { |
| 13 | + return undefined; |
| 14 | + } |
| 15 | + if (obj === null || typeof obj !== "object") { |
| 16 | + return JSON.stringify(obj); |
| 17 | + } |
| 18 | + |
| 19 | + if (Array.isArray(obj)) { |
| 20 | + return JSON.stringify(obj.map((item) => { |
| 21 | + const val = stableStringify(item); |
| 22 | + return val === undefined ? null : JSON.parse(val); |
| 23 | + })); |
| 24 | + } |
| 25 | + |
| 26 | + const sortedKeys = Object.keys(obj as object).sort(); |
| 27 | + const sortedObj: Record<string, unknown> = {}; |
| 28 | + |
| 29 | + for (const key of sortedKeys) { |
| 30 | + const value = (obj as Record<string, unknown>)[key]; |
| 31 | + const str = stableStringify(value); |
| 32 | + // Skip undefined or non-serializable values |
| 33 | + if (str === undefined) continue; |
| 34 | + sortedObj[key] = JSON.parse(str); |
| 35 | + } |
| 36 | + |
| 37 | + return JSON.stringify(sortedObj); |
| 38 | +} |
| 39 | + |
| 40 | +/** |
| 41 | + * Computes the SHA-256 hash of a JSON-serializable object. |
| 42 | + * Returns the hash as a hexadecimal string. |
| 43 | + * |
| 44 | + * @param content - The content to hash (will be JSON serialized) |
| 45 | + * @returns The SHA-256 hash of the content |
| 46 | + * @throws {ValidationError} If content is not serializable (e.g. undefined, function, symbol) |
| 47 | + */ |
| 48 | +export async function sha256Hash(content: unknown): Promise<string> { |
| 49 | + // Use stable stringification to ensure deterministic output |
| 50 | + const jsonString = stableStringify(content); |
| 51 | + |
| 52 | + if (jsonString === undefined) { |
| 53 | + throw new ValidationError(`Content illegal: not serializable (type: ${typeof content})`); |
| 54 | + } |
| 55 | + |
| 56 | + const msgBuffer = new TextEncoder().encode(jsonString); |
| 57 | + |
| 58 | + if (typeof crypto !== "undefined" && crypto.subtle) { |
| 59 | + // Browser / Modern Node.js |
| 60 | + const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer); |
| 61 | + const hashArray = Array.from(new Uint8Array(hashBuffer)); |
| 62 | + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); |
| 63 | + } else { |
| 64 | + // Fallback for older environments or specific setups if global crypto isn't available |
| 65 | + try { |
| 66 | + // Dynamic import to avoid breaking browser builds if bundler doesn't handle it |
| 67 | + |
| 68 | + const { createHash } = await import("node:crypto"); |
| 69 | + const hash = createHash("sha256").update(jsonString).digest("hex"); |
| 70 | + return hash; |
| 71 | + } catch (e) { |
| 72 | + throw new NetworkError("SHA-256 hashing not supported in this environment", e); |
| 73 | + } |
| 74 | + } |
| 75 | +} |
0 commit comments