@woleet/woleet-weblibs
Version:
Woleet web libraries
559 lines (460 loc) • 15.4 kB
JavaScript
/**
* @typedef {Object} ProgressMessage
* @typedef {Number} ProgressMessage.progress (float number)
* @typedef {File} ProgressMessage.file
*/
/**
* @typedef {Object} StartMessage
* @typedef {Boolean} StartMessage.start always true
* @typedef {File} ProgressMessage.file
*/
/**
* @typedef {Object} ErrorMessage
* @typedef {Error} ErrorMessage.error
* @typedef {File} EndMessage.file
*/
/**
* @typedef {Object} EndMessage
* @typedef {String} EndMessage.end hash of the file
* @typedef {File} EndMessage.file
*/
if (typeof window === 'undefined') {
throw new Error('This module is not meant to be used by node')
}
const isSHA256 = (hash) => /^[a-f0-9]{64}$/i.test(hash);
const crypto = require('./woleet-crypto');
const root = window;
const isNativeCryptoSupported = root.crypto && root.crypto.subtle && root.crypto.subtle.digest;
// The default path of the worker scrip:
// same as current script's path or defined by window.woleet.workerScriptPath
const defaultWorkerScriptPath = (function () {
let DEFAULT_WORKER_SCRIPT = "woleet-hashfile-worker.min.js";
let scripts = document.getElementsByTagName('script');
let scriptPath = scripts[scripts.length - 1].src.split('?')[0];
let basePath = scriptPath.split('/').slice(0, -1).join('/') + '/';
let wsp = root.woleet && root.woleet.workerScriptPath ?
root.woleet.workerScriptPath : (basePath ? basePath + DEFAULT_WORKER_SCRIPT : null);
if (!wsp) {
console.error('Cannot find Woleet WebLibs\' default worker script ' + DEFAULT_WORKER_SCRIPT);
return null;
}
console.log('Woleet WebLibs\' default worker script will be loaded from: ', wsp);
return wsp;
})();
function isFileReachable(url) {
const req = new XMLHttpRequest();
return new Promise((resolve) => {
req.onload = () => resolve(req.status === 200);
req.onerror = () => resolve(false);
req.open("GET", url, true);
req.send();
}).catch(() => false);
}
/**
* @constructor
*/
function Hasher(wsp) {
// If a worker script path is provided, use it, or try to figure it out
if (!wsp)
wsp = defaultWorkerScriptPath;
let ready = true;
let cancel = null;
let skip = null;
let _events = {};
const [EVT_START, EVT_PROGRESS, EVT_ERROR, EVT_RESULT, EVT_CANCEL, EVT_SKIP] = ['start', 'progress', 'error', 'result', 'cancel', 'skip'];
const emit = (event, data) => _events[event] && _events[event](data);
const emittable = (event) => _events[event];
const _onCancel = (cb) => cancel = cb;
const _onSkip = (cb) => skip = cb;
const CANCEL_EXCEPTION = '__cancel__';
const SKIP_EXCEPTION = '__skip__';
const HASH_LOCAL_LIMIT = 134217728; // 128MB (more may crash Safari)
const HASH_NATIVE_LIMIT = 1073741824; // 1GB (more may crash Safari)
/**
* @constructor
*/
function HashWorker(wsp) {
let worker = new Worker(wsp);
/**
* @param {File} file
* @returns {Promise}
*/
this.hash = function (file) {
return new Promise((next, reject) => {
let cancelled = false;
let skipped = false;
const reader = new FileReader();
_onCancel(() => {
cancelled = true;
worker.terminate();
reader.abort();
worker = null;
emit(EVT_CANCEL, { file });
reject(CANCEL_EXCEPTION);
});
_onSkip(() => {
skipped = true;
reader.abort();
worker.postMessage({ action: 'reset' });
});
const min = (a, b) => a > b ? b : a;
const bufferSize = 0xfffff, fileSize = file.size;
let start = 0, stop = 0;
worker.onmessage = (event) => {
if (cancelled) {
return;
} else if (event.data.reset) {
emit(EVT_SKIP, { file });
reject(SKIP_EXCEPTION);
return;
} else if (skipped) {
return;
} else if (event.data.result) {
emit(EVT_RESULT, { result: event.data.result, file });
return next();
} else if (event.data.error) {
let message = event.data.error;
reader.abort();
if (emittable(EVT_ERROR)) emit(EVT_ERROR, { error: new Error(message), file });
else return reject(new Error(message));
} else if (event.data.start) {
emit(EVT_START, { start: event.data.start, file });
} else if (event.data.progress) {
emit(EVT_PROGRESS, { progress: stop / fileSize, file });
} else {
console.trace("Unexpected worker message: ", event);
}
if (stop !== fileSize) {
start = stop;
stop = min(stop + bufferSize, fileSize);
reader.readAsArrayBuffer(file.slice(start, stop));
} else {
worker.postMessage({ action: 'finalize' })
}
};
reader.onload = (event) => {
worker.postMessage({
chunk: event.target.result,
action: 'update'
});
};
worker.postMessage({ action: 'start' })
});
};
/**
* @type {Worker}
*/
this.worker = worker;
}
/**
* @param {File} file
* @param {number} limit
* @param {function} reject
* @return {boolean} true if valid
*/
function checkFileSize(file, limit, reject) {
if (file.size > limit) {
ready = true;
let error = new Error("file_too_big_to_be_hashed_without_worker");
if (emittable(EVT_ERROR)) return emit(EVT_ERROR, { error, file });
else reject(error);
return false;
}
return true;
}
/**
* @param {File} file
* @returns {Promise}
*/
function hashLocal(file) {
return new Promise((next, reject) => {
if (!checkFileSize(file, HASH_LOCAL_LIMIT, reject))
return;
let prev = 0;
let cancelled = false;
let skipped = false;
let report_update = false;
const sha256 = crypto.sha256();
const reader = new FileReader();
/**
* @param {ArrayBuffer} buffer
* @param {Number} loaded
*/
function update(buffer, loaded) {
const blob = buffer.slice(prev, loaded);
const chunkUint8 = new Uint8Array(blob);
sha256.update(chunkUint8);
prev = loaded;
}
_onCancel(() => {
cancelled = true;
reader.abort();
emit(EVT_CANCEL, { file })
});
_onSkip(() => {
skipped = true;
reader.abort();
emit(EVT_SKIP, { file })
});
reader.onloadstart = () => {
if (cancelled || skipped) return;
emit(EVT_START, { start: true, file });
};
reader.onload = (event) => {
if (cancelled || skipped) return;
if (report_update) {
//noinspection JSCheckFunctionSignatures,JSUnresolvedVariable
update(event.target.result, event.loaded, event.total);
}
const result = sha256.digest('hex');
emit(EVT_RESULT, { result, file });
next();
};
reader.onprogress = (event) => {
if (cancelled || skipped) return;
let data = event.target.result;
emit(EVT_PROGRESS, { progress: event.loaded / event.total, file });
//noinspection JSUnresolvedVariable
if (!data)
return report_update = true;
//noinspection JSCheckFunctionSignatures,JSUnresolvedVariable
update(data, event.loaded, event.total);
};
reader.onabort = () => {
if (cancelled) {
reject(CANCEL_EXCEPTION);
} else if (skipped) {
reject(SKIP_EXCEPTION);
} else {
reject(new Error('Unhandled abort exception'));
}
};
reader.readAsArrayBuffer(file);
});
}
/**
* @param {File} file
* @returns {Promise}
*/
function hashLocalWithNativeAPI(file) {
return new Promise((resolve, reject) => {
if (!checkFileSize(file, HASH_NATIVE_LIMIT, reject))
return;
const alg = "SHA-256";
let cancelled = false;
let skipped = false;
// entry point
const reader = new FileReader();
_onCancel(() => {
cancelled = true;
reader.abort();
emit(EVT_CANCEL, { file })
});
_onSkip(() => {
skipped = true;
reader.abort();
emit(EVT_SKIP, { file })
});
reader.onloadstart = () => {
if (cancelled || skipped) return;
emit(EVT_START, { start: true, file });
};
reader.onprogress = (event) => {
if (cancelled || skipped) return;
emit(EVT_PROGRESS, { progress: event.loaded / event.total, file });
};
reader.onload = function (event) {
if (cancelled || skipped) return;
let data = event.target.result;
//noinspection JSUnresolvedFunction,JSUnresolvedVariable
root.crypto.subtle.digest(alg, data)
.then((hash) => {
if (cancelled || skipped) return;
let hashResult = new Uint8Array(hash);
let result = hashResult.reduce((res, e) => res + ('00' + e.toString(16)).slice(-2), '');
emit(EVT_RESULT, { result, file });
resolve();
})
.catch((error) => _events[EVT_ERROR] ? emit(EVT_ERROR, { error, file }) : reject(error));
};
reader.onabort = () => {
if (cancelled) {
reject(CANCEL_EXCEPTION);
} else if (skipped) {
reject(SKIP_EXCEPTION);
} else {
reject(new Error('Unhandled abort exception'));
}
};
reader.readAsArrayBuffer(file);
})
}
this.on = function (event, callback) {
switch (event) {
case EVT_START:
case EVT_PROGRESS:
case EVT_ERROR:
case EVT_RESULT:
case EVT_SKIP:
case EVT_CANCEL:
_events[event] = callback;
break;
default:
throw new Error('Invalid event name "' + event + '"');
}
};
this.start = function (files) {
let hashWorker = null; // we may have to keep the hashWorker
if (!ready) throw new Error("not_ready");
ready = false;
// Check input type
if (!(files instanceof FileList || files instanceof File || (Array.isArray(files) && files.every((file) => file instanceof File))))
throw new Error("invalid_parameter");
/**
* @type {Promise.<boolean>}
*/
const isWorkerSupportedPromise =
/**
* Check support for workers.
* @returns {Promise.<boolean>}
*/
function () {
if (!wsp)
return Promise.resolve(false);
const workerReachablePromise = isFileReachable(wsp);
const cryptoReachablePromise = isFileReachable(wsp.split('/').slice(0, -1).join('/')
+ '/woleet-crypto.min.js');
function makeWorker(script) {
let URL = root.URL || window.webkitURL;
let Blob = root.Blob;
let Worker = root.Worker;
if (!URL || !Blob || !Worker || !script)
return null;
let blob = new Blob([script]);
return new Worker(URL.createObjectURL(blob));
}
const workerSupportedPromise = new Promise(function (resolve) {
let syncDetectionScript = "onmessage = function(e) { postMessage(!!FileReaderSync); close() };";
try {
let worker = makeWorker(syncDetectionScript);
if (worker) {
worker.onmessage = function (e) {
worker.terminate();
worker = null;
resolve(e.data);
};
worker.postMessage({});
} else {
resolve(false);
}
}
catch (error) {
resolve(false);
}
});
return Promise.all([workerReachablePromise, cryptoReachablePromise, workerSupportedPromise])
.then((arr) => arr.every((e) => true === e))
}()
.then((support) => {
if (!support)
console.warn('Failed to load worker.');
return support;
});
isWorkerSupportedPromise
.then((workerSupported) => {
/**
* Iterator function with selected hash method
* @param {Number} i current index of the list
* @param {Number} len total size of the list
* @param {FileList|[File]} files file list
*/
function iter(i, len, files) {
if ((i >= len)) {
ready = true;
if (hashWorker && hashWorker.worker) {
hashWorker.worker.terminate();
hashWorker.worker = null;
}
return Promise.resolve();
} else {
// We choose here the best method to hash a file
let hashMethod;
if (files[i].size === 0) {
hashMethod = hashLocal; // Microsoft Edge's native API fails when handling the zero byte file.
} else if (isNativeCryptoSupported && files[i].size <= HASH_NATIVE_LIMIT) {
hashMethod = hashLocalWithNativeAPI;
} else if (workerSupported && files[i].size > HASH_LOCAL_LIMIT) {
// Create hash worker if not yet created
if (!hashWorker)
hashWorker = new HashWorker(wsp);
hashMethod = hashWorker.hash;
} else {
hashMethod = hashLocal;
}
return hashMethod(files[i])
.then(() => iter(i + 1, len, files))
.catch((error) => {
if (error === SKIP_EXCEPTION)
return iter(i + 1, len, files);
if (error !== CANCEL_EXCEPTION)
throw error;
})
}
}
// Entry point
if (files instanceof FileList) { // files is a file list
return iter(0, files.length, files);
} else if (files instanceof File) { // files is a single file
return iter(0, 1, [files]);
} else { // files is an array of File
return iter(0, files.length, files);
}
})
};
this.cancel = function () {
if (cancel) {
cancel();
cancel = null;
ready = true;
}
};
this.skip = function () {
if (cancel) {
skip();
skip = null;
}
};
this.isReady = () => ready;
}
/**
* @param {File|String} file
* @param {Function} [progressCallback]
* @returns {Promise<Hash>}
*/
function hashFileOrCheckHash(file, progressCallback) {
return new Promise((resolve, reject) => {
// If parameter is a file, hash it
if (file instanceof File) {
const hasher = new Hasher;
hasher.on('result', (message, file) => {
resolve(message.result);
if (progressCallback) progressCallback({ progress: 1.0, file })
});
if (progressCallback && typeof progressCallback === 'function')
hasher.on('progress', progressCallback);
hasher.on('error', reject);
hasher.start(file)
}
// If parameter is a hash, check it is a valid SHA256 hash
else if (typeof file === 'string') {
if (isSHA256(file))
resolve(file);
else
reject(new Error("parameter_string_not_a_sha256_hash"));
}
// Invalid parameter
else
reject(new Error("invalid_parameter"));
});
}
module.exports = { Hasher, hashFileOrCheckHash };