UNPKG

@ludovicm67/lib-filetransfer

Version:

Library to help building a file transfer application

329 lines (328 loc) 10.3 kB
import pLimit from "p-limit"; export class TransferFile { id; // file ID // metadata name; type; size; // store data parts = {}; // while fetching content data = undefined; // full data buffer = undefined; bufferLength; // state complete = false; // data is ready and complete errored = false; // an error occured downloading = false; // the file is being downloaded // store error message (or some random information) message = undefined; // configuration timeout = 1; retries = 10; /** * Generate a new TransferFile instance. * * @param id Id of the file. * @param name Name of the file. * @param type Type of the file. * @param size Size of the file. * @param bufferLength Length of the internal buffer. * @param timeout Timeout for a single check in seconds. * @param retries Number of retries before considering it as a failure. */ constructor(id, name, type, size, bufferLength, timeout, retries) { this.id = id; this.name = name; this.type = type; this.size = size; this.bufferLength = bufferLength; if (timeout !== undefined) { this.timeout = timeout; } if (retries !== undefined) { this.retries = retries; } } /** * Set the file as being downloaded. * * @param isDownloading True if the file is being downloaded. */ setDownloading(isDownloading = true) { this.downloading = isDownloading; } /** * Check if the file is downloading. * * @returns true if the file is downloading. */ isDownloading() { return this.downloading; } /** * Set the file as being complete. * * @param isComplete True if the download is complete. */ setComplete(isComplete = true) { this.complete = isComplete; } /** * Check if the file is complete. * * @returns true if the file is complete. */ isComplete() { return this.complete; } /** * Set an error message. * * @param message A relevant error message. * @param isErrored True in case of an error. */ setError(message, isErrored = true) { this.message = message; this.errored = isErrored; } /** * Get informations about the TransferFile. * * @returns Informations about the TransferFile. */ getInfos() { return { id: this.id, name: this.name, size: this.size, bufferLength: this.bufferLength, complete: this.complete, downloading: this.downloading, errored: this.errored, message: this.message, }; } /** * Get the Blob of the complete file. * * @returns The Blob of the file. */ getBlob() { if (!this.isComplete()) { throw new Error("file is incomplete"); } // generate the blob if it does not exist if (this.data === undefined) { this.data = new Blob(Object.keys(this.parts) .sort((x, y) => { const offsetX = parseInt(x.replace(/.*-/, ""), 10); const offsetY = parseInt(y.replace(/.*-/, ""), 10); if (offsetX < offsetY) { return -1; } if (offsetX > offsetY) { return 1; } return 0; }) .map((fPart) => this.parts[fPart]), { type: this.type, }); } return this.data; } /** * Download the file. * * @param maxBufferSize Maximum length for the data to ask at one time. * @param askFilePartCallback Function that will be called to ask for some parts of the file. * @param parallelCalls Number of parallel calls to perform (default value: `1`). * @param timeout Timeout for a single check in seconds. * @param retries Number of retries before considering it as a failure. * @returns */ async download(maxBufferSize, askFilePartCallback, parallelCalls = 1, timeout, retries) { if (this.isComplete()) { // nothing to do, since the file is already complete return; } if (this.isDownloading()) { // nothing to do, since the download action was already triggered return; } if (maxBufferSize <= 0) { throw new Error(`maxBufferSize should be greater than 0, got: ${maxBufferSize}`); } if (timeout === undefined) { timeout = this.timeout; } if (retries === undefined) { retries = this.retries; } this.setDownloading(true); this.setError(undefined, false); try { const limit = pLimit(parallelCalls); const partsCount = Math.ceil(this.bufferLength / maxBufferSize); await Promise.all([...Array(partsCount).keys()].map((offset) => limit(() => { return this.waitFilePartWithRetry(askFilePartCallback, offset * maxBufferSize, maxBufferSize, timeout, retries); }))); this.setComplete(true); this.getBlob(); } catch (e) { const msg = e?.message || "something went wrong"; this.setComplete(false); this.setError(msg); // re-throw the error we catched throw new Error(msg); } this.setDownloading(false); this.parts = {}; } /** * Get the file metadata. * * @returns File metadata. */ getMetadata() { return { id: this.id, name: this.name, size: this.size, type: this.type, bufferLength: this.bufferLength, }; } /** * Get informations representing the file. * * @returns All informations representing the file. */ getFile() { if (!this.isComplete()) { throw new Error("file is incomplete"); } return { name: this.name, type: this.type, size: this.size, data: this.getBlob(), }; } /** * Set a Blob as being the content of this file. */ async setBlob(blob) { const b = new Blob([blob], { type: blob.type }); this.data = b; this.buffer = await b.arrayBuffer(); this.bufferLength = this.buffer.byteLength; this.setComplete(true); this.setDownloading(false); this.setError(undefined, false); } /** * Read `limit` bytes at maximum from `offset` from the file. * * @param offset Offset from the start. * @param limit Maximum number of bytes to return. * @returns ArrayBuffer with the requested file part. */ readFilePart(offset, limit) { if (this.buffer === undefined) { throw new Error(`buffer is not defined for file '#${this.id}'`); } return this.buffer.slice(offset, offset + limit); } /** * Receive a part of the file. * * @param offset Offset from the start. * @param limit The requested limit. * @param data ArrayBuffer containing the requested data. */ receiveFilePart(offset, limit, data) { this.parts[`${limit}-${offset}`] = data; } /** * Check the presence of a specific part of the file. * * @param offset Offset from the start. * @param limit The requested limit. * @returns true if the part exists or if the file is complete. */ hasPart(offset, limit) { return this.parts && this.parts[`${limit}-${offset}`]; } /** * Wait and check for presence of a specific part of the file. * * @param offset Offset from the start. * @param limit The requested limit. * @param timeout Timeout in seconds (default: `1`) * @returns true of the part was received. */ async waitFilePart(offset, limit, timeout = 1) { if (this.isComplete()) { return true; } for (let i = timeout * 10; i >= 0; i--) { if (this.hasPart(offset, limit)) { return true; } await new Promise((r) => setTimeout(r, 100)); } return false; } /** * Wait for a specific part of a file, with some retries. * * @param askFilePartCallback Function to ask a file part to the sender. * @param offset Offset from the start. * @param limit Maximum number of bytes that we can read. * @param timeout Timeout for a single check in seconds. * @param retries Number of retries before considering it as a failure. */ async waitFilePartWithRetry(askFilePartCallback, offset, limit, timeout, retries) { if (timeout === undefined) { timeout = this.timeout; } if (retries === undefined) { retries = this.retries; } // no need to ask for this part if it already exists if (this.hasPart(offset, limit)) { return; } if (!this.isDownloading()) { throw new Error("download aborted"); } let success = false; askFilePartCallback(this.id, offset, limit); for (let i = retries; i >= 0; i--) { const receivedPart = await this.waitFilePart(offset, limit, timeout); if (receivedPart) { success = true; break; } // in case of a failure, retry askFilePartCallback(this.id, offset, limit); } if (!success) { throw new Error(`missing part (limit=${limit}, offset=${offset}) for file '#${this.id}'`); } } /** * Clear the content of the file. * The user will need to download it again. */ clear() { this.setComplete(false); this.setDownloading(false); this.setError(undefined, false); this.data = undefined; this.buffer = undefined; this.parts = {}; } }