diff --git a/README.md b/README.md index 4412ef5..eabf3f4 100644 --- a/README.md +++ b/README.md @@ -27,23 +27,26 @@ Import: ```js // ESM -import { hash, objectHash, murmurHash, sha256 } from "ohash"; +import { hash, hashAsync, objectHash, murmurHash, sha256 } from "ohash"; // CommonJS -const { hash, objectHash, murmurHash, sha256 } = require("ohash"); +const { hash, hashAsync, objectHash, murmurHash, sha256 } = require("ohash"); ``` -### `hash(object, options?)` +### `hash(object, options?)` / `hashAsync(object, options?)` Converts object value into a string hash using `objectHash` and then applies `sha256` with Base64 encoding (trimmed by length of 10). +`hashAsync` is slightly faster as will leverage [`SubtleCrypto.digest`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest) when available. + Usage: ```js -import { hash } from "ohash"; +import { hash, hashAsync } from "ohash"; // "dZbtA7f0lK" console.log(hash({ foo: "bar" })); +console.log(await hashAsync({ foo: "bar" })); ``` ### `objectHash(object, options?)` @@ -121,26 +124,28 @@ import { murmurHash } from "ohash"; console.log(murmurHash("Hello World")); ``` -### `sha256` +### `sha256` / `sha256Async` Create a secure [SHA 256](https://en.wikipedia.org/wiki/SHA-2) digest from input string. ```js -import { sha256 } from "ohash"; +import { sha256, sha256Async } from "ohash"; // "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e" console.log(sha256("Hello World")); +console.log(await sha256Async("Hello World")); ``` -### `sha256base64` +### `sha256base64` / `sha256base64Async` Create a secure [SHA 256](https://en.wikipedia.org/wiki/SHA-2) digest in Base64 encoding from input string. ```js -import { sha256base64 } from "ohash"; +import { sha256base64, sha256base64Async } from "ohash"; // "pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4" console.log(sha256base64("Hello World")); +console.log(await sha256base64Async("Hello World")); ``` ## 💻 Development diff --git a/benchmark/_utils.mjs b/benchmark/_utils.mjs new file mode 100644 index 0000000..4caf13f --- /dev/null +++ b/benchmark/_utils.mjs @@ -0,0 +1,16 @@ +export function generateItems(num) { + return new Array(num).fill(0).map(() => { + return { + propNum: Math.random(), + propBool: Math.random() > 0.5, + propString: Math.random().toString(16), + propDate: new Date(), + propObj: { + propNum: Math.random(), + propBool: Math.random() > 0.5, + propString: Math.random().toString(16), + propDate: new Date(), + }, + }; + }); +} diff --git a/benchmark/hash.mjs b/benchmark/hash.mjs new file mode 100644 index 0000000..0c32c44 --- /dev/null +++ b/benchmark/hash.mjs @@ -0,0 +1,34 @@ +import Benchmark from "benchmark"; +import { hash, hashAsync } from "ohash"; +import largeJson from "./fixture/large.mjs"; +import { generateItems } from "./_utils.mjs"; + +const dataSets = { + emptyObject: {}, + singleObject: generateItems(1)[0], + tinyArray: generateItems(10), + mediumArray: generateItems(100), + largeArray: generateItems(1000), + largeJson, +}; + +const suite = new Benchmark.Suite(); + +for (const [name, data] of Object.entries(dataSets)) { + suite.add(`hash(${name})`, () => { + hash(data); + }); + suite.add( + `hashAsync(${name})`, + (ctx) => { + hashAsync(data).then(() => ctx.resolve()); + }, + { defer: true }, + ); +} + +suite + .on("cycle", (event) => { + console.log(event.target.toString()); + }) + .run({ async: false }); diff --git a/benchmark/object-hash.mjs b/benchmark/object-hash.mjs index f328f22..c179ecf 100644 --- a/benchmark/object-hash.mjs +++ b/benchmark/object-hash.mjs @@ -1,51 +1,36 @@ import Benchmark from "benchmark"; import { objectHash } from "ohash"; import largeJson from "./fixture/large.mjs"; +import { generateItems } from "./_utils.mjs"; -function generateItems(num) { - return new Array(num).fill(0).map(() => { - return { - propNum: Math.random(), - propBool: Math.random() > 0.5, - propString: Math.random().toString(16), - propDate: new Date(), - propObj: { - propNum: Math.random(), - propBool: Math.random() > 0.5, - propString: Math.random().toString(16), - propDate: new Date(), - }, - }; - }); -} - -const suite = new Benchmark.Suite(); const singleObject = generateItems(1)[0]; const tinyArray = generateItems(10); const mediumArray = generateItems(100); const largeArray = generateItems(1000); -suite.add("hash({})", function () { +const suite = new Benchmark.Suite(); + +suite.add("objectHash({})", function () { const v = objectHash({}); }); -suite.add("hash(singleObject)", function () { +suite.add("objectHash(singleObject)", function () { const v = objectHash(singleObject); }); -suite.add("hash(tinyArray)", function () { +suite.add("objectHash(tinyArray)", function () { const v = objectHash(tinyArray); }); -suite.add("hash(mediumArray)", function () { +suite.add("objectHash(mediumArray)", function () { const v = objectHash(mediumArray); }); -suite.add("hash(largeArray)", function () { +suite.add("objectHash(largeArray)", function () { const v = objectHash(largeArray); }); -suite.add("hash(largeJson)", function () { +suite.add("objectHash(largeJson)", function () { const v = objectHash(largeJson); }); diff --git a/src/crypto/sha256.ts b/src/crypto/sha256.ts index de9a20f..46a1f88 100644 --- a/src/crypto/sha256.ts +++ b/src/crypto/sha256.ts @@ -148,6 +148,15 @@ export function sha256(message: string) { return new SHA256().finalize(message).toString(); } +export function sha256Async(message: string) { + if (!globalThis.crypto?.subtle?.digest) { + return new SHA256().finalize(message).toString(); + } + return globalThis.crypto.subtle + .digest("SHA-256", new TextEncoder().encode(message)) + .then((digest) => arrayBufferToHex(digest)); +} + /** * Calculates the SHA-256 hash of the given message and encodes it in Base64. * @@ -157,3 +166,41 @@ export function sha256(message: string) { export function sha256base64(message: string) { return new SHA256().finalize(message).toString(Base64); } + +export function sha256base64Async(message: string) { + if (!globalThis.crypto?.subtle?.digest) { + return Promise.resolve(sha256base64(message)); + } + return globalThis.crypto.subtle + .digest("SHA-256", new TextEncoder().encode(message)) + .then((digest) => arrayBufferToBase64(digest)); +} + +// --- internal --- + +function arrayBufferToBase64(buffer: ArrayBuffer) { + if (globalThis.Buffer) { + return globalThis.Buffer.from(buffer) + .toString("base64") + .replace(/[+/=]/g, ""); + } + let binary = ""; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/[+/=]/g, ""); +} + +function arrayBufferToHex(buffer: ArrayBuffer) { + if (globalThis.Buffer) { + return globalThis.Buffer.from(buffer).toString("hex"); + } + const bytes = new Uint8Array(buffer); + const hex = []; + for (const byte of bytes) { + hex.push(byte.toString(16).padStart(2, "0")); + } + return hex.join(""); +} diff --git a/src/hash.ts b/src/hash.ts index b6a3186..af5b221 100644 --- a/src/hash.ts +++ b/src/hash.ts @@ -1,5 +1,5 @@ import { objectHash, HashOptions } from "./object-hash"; -import { sha256base64 } from "./crypto/sha256"; +import { sha256base64, sha256base64Async } from "./crypto/sha256"; /** * Hash any JS value into a string @@ -13,3 +13,19 @@ export function hash(object: any, options: HashOptions = {}): string { typeof object === "string" ? object : objectHash(object, options); return sha256base64(hashed).slice(0, 10); } + +/** + * Hash any JS value into a string + * @param {object} object value to hash + * @param {HashOptions} options hashing options + * @return {Promise} hash value + * @api public + */ +export function hashAsync( + object: any, + options: HashOptions = {}, +): Promise { + const hashed = + typeof object === "string" ? object : objectHash(object, options); + return sha256base64Async(hashed).then((digest) => digest.slice(0, 10)); +} diff --git a/src/index.ts b/src/index.ts index bde8db8..fdd6066 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,11 @@ export { objectHash } from "./object-hash"; -export { hash } from "./hash"; +export { hash, hashAsync } from "./hash"; export { murmurHash } from "./crypto/murmur"; -export { sha256, sha256base64 } from "./crypto/sha256"; +export { + sha256, + sha256Async, + sha256base64, + sha256base64Async, +} from "./crypto/sha256"; export { isEqual } from "./utils"; export { diff } from "./diff"; diff --git a/test/index.test.ts b/test/index.test.ts index 3e70433..5418796 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from "vitest"; -import { murmurHash, objectHash, hash, sha256, isEqual, diff } from "../src"; -import { sha256base64 } from "../src/crypto/sha256"; +import { + murmurHash, + objectHash, + hash, + sha256, + sha256Async, + sha256base64, + sha256base64Async, + isEqual, + diff, + hashAsync, +} from "../src"; describe("objectHash", () => { it("basic object", () => { @@ -79,6 +89,15 @@ it("sha256", () => { ); }); +it("sha256Async", async () => { + expect(await sha256Async("Hello World")).toMatchInlineSnapshot( + '"a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e"', + ); + expect(await sha256Async("")).toMatchInlineSnapshot( + '"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"', + ); +}); + it("sha256base64", () => { expect(sha256base64("Hello World")).toMatchInlineSnapshot( '"pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4"', @@ -88,10 +107,23 @@ it("sha256base64", () => { ); }); +it("sha256base64Async", async () => { + expect(await sha256base64Async("Hello World")).toMatchInlineSnapshot( + '"pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4"', + ); + expect(await sha256base64Async("")).toMatchInlineSnapshot( + '"47DEQpj8HBSaTImW5JCeuQeRkm5NMpJWZG3hSuFU"', + ); +}); + it("hash", () => { expect(hash({ foo: "bar" })).toMatchInlineSnapshot('"dZbtA7f0lK"'); }); +it("hashAsync", async () => { + expect(await hashAsync({ foo: "bar" })).toMatchInlineSnapshot('"dZbtA7f0lK"'); +}); + describe("isEqual", () => { const cases = [ [{ foo: "bar" }, { foo: "bar" }, true],