perf(web): use hash-wasm SHA-1 with pure-JS fallback for upload hashing (#30107)

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
Moshiour Rahman
2026-08-24 20:24:04 +02:00
committed by GitHub
co-authored by Daniel Dietzler
parent 1b3aa9cd51
commit 0b75b20267
4 changed files with 82 additions and 1 deletions
+8
View File
@@ -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
+1
View File
@@ -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",
+47
View File
@@ -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);
});
});
+26 -1
View File
@@ -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<string> {
export async function hashFileWasm(file: File): Promise<string> {
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<string> {
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<string> {
return bytesToHex(hasher.digest());
}
export async function hashFile(file: File): Promise<string> {
try {
return await hashFileWasm(file);
} catch {
return hashFileJs(file);
}
}
// eslint-disable-next-line unicorn/no-top-level-side-effects
addEventListener('message', (event: MessageEvent<File>) => {
void hashFile(event.data)
.then((result) => postMessage({ result }))