From 0b75b202674fba95316529f0edc34a69f184a533 Mon Sep 17 00:00:00 2001 From: Moshiour Rahman Date: Tue, 25 Aug 2026 00:24:04 +0600 Subject: [PATCH] perf(web): use hash-wasm SHA-1 with pure-JS fallback for upload hashing (#30107) Co-authored-by: Daniel Dietzler --- pnpm-lock.yaml | 8 +++++ web/package.json | 1 + web/src/lib/workers/hash-file.spec.ts | 47 +++++++++++++++++++++++++++ web/src/lib/workers/hash-file.ts | 27 ++++++++++++++- 4 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/workers/hash-file.spec.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c437996c..43ebe6a80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -857,6 +857,9 @@ importers: happy-dom: specifier: ^20.0.0 version: 20.11.2 + hash-wasm: + specifier: ^4.12.0 + version: 4.12.0 hls-video-element: specifier: ^1.5.11 version: 1.5.11 @@ -8195,6 +8198,9 @@ packages: resolution: {integrity: sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hash-wasm@4.12.0: + resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -21390,6 +21396,8 @@ snapshots: has-yarn@3.0.0: {} + hash-wasm@4.12.0: {} + hasown@2.0.4: dependencies: function-bind: 1.1.2 diff --git a/web/package.json b/web/package.json index 28695c628..cb3558527 100644 --- a/web/package.json +++ b/web/package.json @@ -46,6 +46,7 @@ "geojson": "^0.5.0", "handlebars": "^4.7.8", "happy-dom": "^20.0.0", + "hash-wasm": "^4.12.0", "hls-video-element": "^1.5.11", "hls.js": "^1.6.16", "intl-messageformat": "^11.0.0", diff --git a/web/src/lib/workers/hash-file.spec.ts b/web/src/lib/workers/hash-file.spec.ts new file mode 100644 index 000000000..f56f56fcd --- /dev/null +++ b/web/src/lib/workers/hash-file.spec.ts @@ -0,0 +1,47 @@ +const content = 'hello immich, this is a deterministic test payload'; +const knownSha1Hex = 'b5827d64afd7754a765416e79f9329ca6cebd07b'; + +describe('hashFile', () => { + afterEach(() => { + vi.doUnmock('hash-wasm'); + vi.resetModules(); + }); + + it('hashFileJs produces the correct SHA-1 hex digest', async () => { + const { hashFileJs } = await import('./hash-file'); + const file = new File([content], 'a.txt'); + await expect(hashFileJs(file)).resolves.toBe(knownSha1Hex); + }); + + it('hashFileWasm produces the correct SHA-1 hex digest', async () => { + const { hashFileWasm } = await import('./hash-file'); + const file = new File([content], 'a.txt'); + await expect(hashFileWasm(file)).resolves.toBe(knownSha1Hex); + }); + + it('hashFileWasm and hashFileJs produce identical output for the same input', async () => { + const { hashFileWasm, hashFileJs } = await import('./hash-file'); + for (const size of [0, 1, 1024, 10_000]) { + const file = new File([new Uint8Array(size)], 'x.bin'); + const [wasmHash, jsHash] = await Promise.all([hashFileWasm(file), hashFileJs(file)]); + expect(wasmHash).toBe(jsHash); + } + }); + + it('falls back to the JS implementation when the WASM path throws', async () => { + vi.doMock('hash-wasm', () => ({ + createSHA1: () => Promise.reject(new Error('boom')), + })); + vi.resetModules(); + + const { hashFile } = await import('./hash-file'); + const file = new File([content], 'a.txt'); + await expect(hashFile(file)).resolves.toBe(knownSha1Hex); + }); + + it('uses the WASM path when available and matches the JS output', async () => { + const { hashFile } = await import('./hash-file'); + const file = new File([content], 'a.txt'); + await expect(hashFile(file)).resolves.toBe(knownSha1Hex); + }); +}); diff --git a/web/src/lib/workers/hash-file.ts b/web/src/lib/workers/hash-file.ts index 6927eb7db..d315cf967 100644 --- a/web/src/lib/workers/hash-file.ts +++ b/web/src/lib/workers/hash-file.ts @@ -1,9 +1,25 @@ import { sha1 } from '@noble/hashes/legacy.js'; import { bytesToHex } from '@noble/hashes/utils.js'; +import { createSHA1 } from 'hash-wasm'; const HASH_CHUNK_SIZE = 5 * 1024 * 1024; -async function hashFile(file: File): Promise { +export async function hashFileWasm(file: File): Promise { + const sha1Factory = await createSHA1(); + const hasher = sha1Factory.init(); + + for (let offset = 0; offset < file.size; offset += HASH_CHUNK_SIZE) { + const slice = file.slice(offset, Math.min(offset + HASH_CHUNK_SIZE, file.size)); + + const buffer = await slice.arrayBuffer(); + + hasher.update(new Uint8Array(buffer)); + } + + return hasher.digest(); +} + +export async function hashFileJs(file: File): Promise { const hasher = sha1.create(); for (let offset = 0; offset < file.size; offset += HASH_CHUNK_SIZE) { @@ -17,6 +33,15 @@ async function hashFile(file: File): Promise { return bytesToHex(hasher.digest()); } +export async function hashFile(file: File): Promise { + try { + return await hashFileWasm(file); + } catch { + return hashFileJs(file); + } +} + +// eslint-disable-next-line unicorn/no-top-level-side-effects addEventListener('message', (event: MessageEvent) => { void hashFile(event.data) .then((result) => postMessage({ result }))