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:
co-authored by
Daniel Dietzler
parent
1b3aa9cd51
commit
0b75b20267
Generated
+8
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }))
|
||||
|
||||
Reference in New Issue
Block a user