import { saveAs } from 'file-saver'; import { BlobWriter, TextReader, ZipWriter, ZipWriterConstructorOptions } from '@zip.js/zip.js'; import { AbstractErrorManager, AbstractErrorObject, AbstractHistoryObject, ExportObject, StoreObject, } from './types'; import { createInstance } from 'localforage'; import { EventDispatcher } from './core/EventDispatcher'; import { EVENT_TYPES } from './constants'; import { getUser } from './utils'; const STORE_NOT_DEFINED = 'Store is not defined.' as const; /** * Error manager that handles the application's `errors` and the user's `history`. * * If the app crashes, the `errors` and `history` will be exported to a zip file. * The zip file will be encrypted with a password. The password is `123456789`. * * This means that you, as a developer, can read the `errors` and `history` of the user. * No more guessing or vague descriptions of the error. * * You can only read the `errors` and `history` of the user if the user sends you the zip file. * The zip file will be saved in the `downloads` folder of the user's device. * The zip file will be named `MyErrorReport_.errorReport`. * * No data will be sent to any server. You need to add that yourself :) */ export class ErrorManager extends EventDispatcher implements AbstractErrorManager { /** * {@link ErrorManager} singleton */ private static _instance: ErrorManager; /** * A flag indicating whether debug mode is enabled. * When set to `true`, additional debug information may be logged. * This can be useful for development and troubleshooting purposes. */ public static DEBUG = true; /** * A constant string that serves as the prefix for error report files. * This prefix is used to name the error report files generated by the ErrorManager. */ public static FILE_PREFIX = 'MyErrorReport' as const; /** * The file extension used for error reports. * This is a constant value set to 'errorReport'. */ public static FILE_EXTENSION = 'errorReport' as const; public static PASSWORD = '123456789' as const; public static STORE_NAME = 'errorStore' as const; public static STORE_KEY = 'errors' as const; /** * Errors that have been thrown. */ private _errors: AbstractErrorObject[]; /** * Log of all actions that have been performed. */ private _history: AbstractHistoryObject[]; /** * A private property that optionally holds the return type of the `createInstance` function. * This property is used to store an instance created by the `createInstance` function. */ private _store?: ReturnType; /** * Generate {@link ViewerOverlord} singleton */ public static get Instance() { return this._instance || (this._instance = new this()); } /** * Constructs an instance of the ErrorManager class. * * @param {boolean} [attemptClear=true] - Indicates whether to attempt to clear errors asynchronously upon initialization. * @param {ReturnType} [store] - An optional store instance created by the `createInstance` function. * @example * ```ts * const errorManager = new ErrorManager(true, * createInstance({ name: ErrorManager.STORE_NAME, driver: INDEXEDDB }) * ); * ``` */ constructor(attemptClear: boolean = true, store?: ReturnType) { super(); this._errors = []; this._history = []; if (attemptClear) this.attemptClearAsync(); if (store !== undefined) this._store = store; } /** * Handles an error by performing several actions: * 1. Validates if the provided object is an error object. * 2. Adds the error object to the internal errors list. * 3. Asynchronously saves the error. * 4. Dispatches an error event. * * @param _error - The error object to handle. Must be an instance of AbstractErrorObject. * @returns A promise that resolves to an Error indicating the method is not fully implemented. * @throws Will throw an error if the provided object is not a valid error object. */ public async error(_error: AbstractErrorObject): Promise { if (!_error.isErrorObject) throw new Error("Object isn't a error object."); this._errors.push(_error); this._saveAsync(); this.dispatchEvent({ type: EVENT_TYPES.ERROR, error: _error }); return Error('Method not implemented.'); } /** * Retrieves the list of errors. * * @returns {Readonly} A read-only array of error objects. */ public getErrors(): Readonly { return this._errors; } /** * Adds a history object to the history list, saves the history asynchronously, * and dispatches a history event. * * @param _history - The history object to be added. Must implement the AbstractHistoryObject interface. * @throws {Error} If the provided object is not a valid history object. * @returns {Promise} A promise that resolves when the history object has been added and saved. */ public async addHistory(_history: AbstractHistoryObject): Promise { if (!_history.isHistoryObject) throw new Error("Object isn't a history object."); this._history.push(_history); this._saveAsync(); this.dispatchEvent({ type: EVENT_TYPES.HISTORY, history: _history }); } /** * Retrieves the history of error objects. * * @returns {Readonly} An array of history objects that is read-only. */ public getHistory(): Readonly { return this._history; } /** * Saves the current state of errors and history to the store asynchronously. * * @throws {Error} If the store is not defined. * @returns {Promise} A promise that resolves when the state has been saved. * @private */ private async _saveAsync(): Promise { if (this._store == null) throw new Error(STORE_NOT_DEFINED); const storeObject: StoreObject = { errors: this._errors, history: this._history, }; await this._store.setItem(ErrorManager.STORE_KEY, storeObject); } /** * Clears the current errors and history from the error manager. * * This method will reset the `_errors` and `_history` arrays to empty arrays. * It will then update the store with the cleared state. * * @throws {Error} If the store is not defined. * @returns {Promise} A promise that resolves when the store has been updated. */ async clear(): Promise { if (this._store == null) throw new Error(STORE_NOT_DEFINED); this._errors = []; this._history = []; const storeObject: StoreObject = { errors: this._errors, history: this._history, }; await this._store.setItem(ErrorManager.STORE_KEY, storeObject); } /** * Attempts to clear the errors and history asynchronously. * * @returns {Promise} A promise that resolves to `true` if the errors and history were cleared, * or `false` if they were loaded from the store. * * @throws {Error} If the store is not defined. * * The method performs the following steps: * 1. Checks if the store is defined. If not, throws an error. * 2. Determines if the errors and history should be cleared by calling `shouldClear`. * 3. If they should be cleared, calls `clear` and returns `true`. * 4. If not, attempts to load the errors and history from the store. * 5. If loading from the store fails, clears the errors and history and returns `true`. * 6. If loading from the store succeeds, sets the errors and history to the loaded values and returns `false`. */ public async attemptClearAsync(): Promise { if (this._store == null) throw new Error(STORE_NOT_DEFINED); // Determine if the `errors` and `history` should be cleared. if (await this.shouldClear()) { await this.clear(); return true; } else { // If not, attempt to load the `errors` and `history` from the store. const object: StoreObject | null = await this._store.getItem(ErrorManager.STORE_KEY); // If failed, clear the `errors` and `history` anyway. if (object == null) { await this.clear(); return true; } // Finally, set the `errors` and `history` to the loaded values. this._history = object.history; this._errors = object.errors; } return false; } /** * Determine if the `errors` and `history` should be cleared. * Override this method to change the behavior. * @returns `true` if the `errors` and `history` were cleared. */ protected async shouldClear(): Promise { if (this._store == null) throw new Error(STORE_NOT_DEFINED); // If in debug mode, always clear if (ErrorManager.DEBUG) return true; const storeObject: StoreObject | null = await this._store.getItem(ErrorManager.STORE_KEY); if (storeObject === null) return true; // Clear just to be safe const latestError: AbstractErrorObject | undefined = storeObject.errors[storeObject.errors.length - 1]; if (latestError === undefined) return true; // Clear just to be safe const timestamp: Date | undefined = latestError.timestamp; if (timestamp === undefined) return true; // Clear just to be safe const today = new Date(); // Check if one day has passed since the last error if (latestError.timestamp.getDate() !== today.getDate()) { this.clear(); this._saveAsync(); return true; } return false; } /** * Retrieves the user information. * * @returns {User} The user object. */ private _getUser() { return getUser(); } /** * Generates a file name based on the current date and time. * * If the `DEBUG` flag is set to `true`, it returns 'debug.zip'. * Otherwise, it generates a file name using the current date and time, * replacing all non-alphanumeric characters with underscores. * * @returns {string} The generated file name. */ private _getFileName(): string { if (ErrorManager.DEBUG) return 'debug.zip'; // Get current date and time as string const now = new Date().toISOString(); // Replace all characters that are not numbers or letters with an underscore const nowString = now.replace(/[^a-zA-Z0-9]/g, '_'); return `${ErrorManager.FILE_PREFIX}_${nowString}.${ErrorManager.FILE_EXTENSION}`; } /** * Generates a ZIP file blob containing the user's history, errors, and user information. * * @private * @async * @returns {Promise} A promise that resolves to a Blob representing the ZIP file. * * @remarks * - If `ErrorManager.DEBUG` is true, the ZIP file will not be encrypted. * - If `ErrorManager.DEBUG` is false, the ZIP file will be encrypted with the password specified in `ErrorManager.PASSWORD` and an encryption strength of 1. * * @throws {Error} If there is an issue creating or adding files to the ZIP. */ private async _getZipFileBlob() { const params: ZipWriterConstructorOptions = ErrorManager.DEBUG ? {} : { password: ErrorManager.PASSWORD, encryptionStrength: 1, }; const zipWriter = new ZipWriter(new BlobWriter('application/zip'), params); const history = this._convertHistory(); const errors = this._convertErrors(); const historyPromises = Object.entries(history).map(([fileName, data], index: number) => zipWriter.add( `history/${index}_${fileName}.json`, new TextReader(JSON.stringify(data)), ), ); const errorPromises = Object.entries(errors).map(([fileName, data], index: number) => zipWriter.add(`errors/${index}_${fileName}.json`, new TextReader(JSON.stringify(data))), ); const user = this._getUser(); const userPromise = zipWriter.add(`user.json`, new TextReader(JSON.stringify(user))); await Promise.all([...historyPromises, ...errorPromises, userPromise]); return zipWriter.close(); } /** * Converts the internal history array to an exportable object. * Each entry in the history is keyed by its timestamp in ISO string format. * * @returns {ExportObject} An object where each key is a timestamp and each value is the corresponding history entry in JSON format. */ private _convertHistory(): ExportObject { const history: ExportObject = {}; this._history.forEach((_history) => { history[_history.timestamp.toISOString()] = JSON.parse(_history.toJSON()); }); return history; } /** * Converts the internal errors array to an exportable object. * Each error is keyed by its timestamp in ISO string format. * * @returns {ExportObject} An object where each key is an error's timestamp * in ISO string format and each value is the error serialized to JSON. */ private _convertErrors(): ExportObject { const errors: ExportObject = {}; this._errors.forEach((_error) => { errors[_error.timestamp.toISOString()] = JSON.parse(_error.toJSON()); }); return errors; } /** * Exports the error data as a ZIP file. * * @param {boolean} [downloadFile=true] - Whether to download the file after exporting. * @returns {Promise<{ fileName: string; blob: Blob }>} A promise that resolves with the file name and the Blob object of the ZIP file. * * @fires EVENT_TYPES.EXPORT_BEGIN - Dispatched when the export process begins. * @fires EVENT_TYPES.EXPORT_DONE - Dispatched when the export process is completed. */ public async export(downloadFile: boolean = true): Promise<{ fileName: string; blob: Blob; }> { const fileName = this._getFileName(); this.dispatchEvent({ type: EVENT_TYPES.EXPORT_BEGIN }); const blob = await this._getZipFileBlob(); this.dispatchEvent({ type: EVENT_TYPES.EXPORT_DONE }); if (downloadFile) this._downloadFile(blob, fileName); return { fileName, blob }; } /** * Downloads a file using the provided Blob object and file name. * * @param blob - The Blob object representing the file to be downloaded. * @param fileName - The name to be given to the downloaded file. * @private */ private _downloadFile(blob: Blob, fileName: string): void { saveAs(blob, fileName); } }