@villedemontreal/general-utils
Version:
General utilities library
511 lines • 18.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.utils = exports.ExecError = exports.Utils = void 0;
exports.getValueDescription = getValueDescription;
exports.getValueDescriptionWithType = getValueDescriptionWithType;
const child_process_1 = require("child_process");
const fs = require("fs");
const getPort = require("get-port");
const pathUtils = require("path");
const rimraf_1 = require("rimraf");
const tsconfig = require("tsconfig-extends");
const constants_1 = require("./config/constants");
const lodash_1 = require("lodash");
/**
* General utilities
*/
class Utils {
constructor() {
/**
* Creates a "range", an array of continuous integers, from
* "start" to "end".
* Both "start" and "end" are inclusive.
*/
this.range = (start, end) => {
return [...Array(1 + end - start).keys()].map((v) => start + v);
};
/**
* To be used as a "@Transform" decorator on a
* Date field, when using the "class-transformer"
* library. For example :
*
* @Transform(utils.dateTransformer)
* public created: Date;
*
* When using what class-transformer provides
* by default ("@Type(() => Date)") there are cases
* that are problematic :
* - "123" and "true" :result in valid dates!
*
* Note : *only* use this decorator on the Date field, not
* in addition to "@Type(() => Date)"!
*/
this.dateTransformer = (value) => {
let date;
if ((0, lodash_1.isNil)(value)) {
return null;
}
if ((0, lodash_1.isDate)(value)) {
date = value;
}
else if (!(0, lodash_1.isString)(value) || exports.utils.isBlank(value)) {
// ==========================================
// Makes sure it's an invalid date!
// Because by default, true and 123 are accepted,
// and are transform to valid dates!
// ==========================================
date = new Date(`invalid!`);
}
else {
date = new Date(value);
}
return date;
};
/**
* Throws an Error for the specified element as type "never".
*
* To be used in the "default" section of a switch/case statement.
* This allows the validation at compile time that all elements of the
* swtiched element are managed (as long as it is a discret type such
* as an enum).
*/
this.throwNotManaged = (messagePrefix, element) => {
throw new Error(`${messagePrefix}: ${element}`);
};
/**
* Returns TRUE if the parameter is an object but is not an array,
* a Date or a function
* By default, _.isObject(x) from Lodash also returns TRUE for
* an Array, for a Date and a function.
*
* Returns FALSE for null/undefined.
*/
this.isObjectStrict = (val) => {
if (!val) {
return false;
}
return (0, lodash_1.isObject)(val) && !(0, lodash_1.isArray)(val) && !(0, lodash_1.isDate)(val) && !(0, lodash_1.isFunction)(val);
};
/**
* Returns TRUE if the specified "array" contains at least one object
* that has the specified "key" and if the value associated with that key is
* strictly equals to the specified "value".
*/
this.arrayContainsObjectWithKeyEqualsTo = (array, key, value) => {
if (!array || !(0, lodash_1.isArray)(array) || array.length < 1) {
return false;
}
for (const obj of array) {
if (this.isObjectStrict(obj) && (0, lodash_1.isEqual)(obj[key], value)) {
return true;
}
}
return false;
};
}
/**
* Promisified setTimeout() utility function.
*
* @param ms The number of milliseconds to sleep for.
*/
async sleep(ms) {
await new Promise((resolve, reject) => {
setTimeout(resolve, ms);
});
}
/**
* Checks if a String is null, undefined,
* empty, or contains only whitespaces.
*/
isBlank(str) {
if (str === null || str === undefined) {
return true;
}
if (!(typeof str === 'string')) {
return false;
}
return str.trim() === '';
}
/**
* A better version of "isNaN()".
* For example, an empty string is NOT considered as a
* number.
*/
isNaNSafe(value) {
if (isNaN(value) || this.isBlank(value)) {
return true;
}
const type = typeof value;
if (type !== 'string' && type !== 'number') {
return true;
}
return false;
}
/**
* If the "el" is not undefined, returns it as is.
* If the el is undefined, returns NULL.
*
* This is useful when undefined is not acceptable
* but null is.
*/
getDefinedOrNull(el) {
if (el === undefined) {
return null;
}
return el;
}
/**
* Checks if a value is an integer.
*
* If you want to use the includeZero parameter without
* using the positiveOnly parameter, we suggest to pass
* undefined as a second parameter.
*
* After a positive check, we suggest to
* pass the value with the Number object (Number(value))
* to "clean" it, e.g., getting rid of unmeaningful
* decimal zeros or whitespaces.
*/
isIntegerValue(value, positiveOnly = false, includeZero = true) {
if (this.isNaNSafe(value)) {
return false;
}
// Convert to Number, if not already one
const asNumber = Number(value);
if (positiveOnly && asNumber < 0) {
return false;
}
if (!includeZero && asNumber === 0) {
return false;
}
// Busts integer safe limits
if (asNumber > Number.MAX_SAFE_INTEGER || asNumber < Number.MIN_SAFE_INTEGER) {
return false;
}
// If there were decimals but "0" only, it is
// still considered as an Integer, and Number(value)
// still have stripped those decimals....
if ((asNumber + '').indexOf('.') > -1) {
return false;
}
return true;
}
/**
* Converts a string to a boolean.
*
* The string is TRUE only if it is
* "true" (case insensitive) or "1"
* (the *number* 1 is also accepted)
*
* Otherwise, it is considered as FALSE.
*/
stringToBoolean(str) {
if (str === null || str === undefined) {
return false;
}
let strClean = str;
if (typeof strClean === 'number') {
strClean = str + '';
}
else if (typeof strClean !== 'string') {
return false;
}
strClean = strClean.toLowerCase();
if (strClean === 'true' || strClean === '1') {
return true;
}
return false;
}
/**
* Make sure a file is safe to delete, that is:
* - It is truly
* - It is not the path of a root directory or file
*/
isSafeToDelete(path) {
if (!path) {
return false;
}
let pathClean = path;
pathClean = pathUtils.normalize(pathClean);
pathClean = pathClean.replace(/\\/g, '/');
pathClean = (0, lodash_1.trimEnd)(pathClean, '/ ');
return (pathClean.match(/\//g) || []).length > 1;
}
/**
* Checks if a path points to an existing directory.
*
* @returns true if the path points to an existing
* directory (not a file).
*/
isDir(dirPath) {
if (!dirPath || !fs.existsSync(dirPath)) {
return false;
}
return fs.lstatSync(dirPath).isDirectory();
}
/**
* Checks if a directory is empty.
*
* @returns true if the directory is empty or
* doesn't exist. Returns false if the path
* points to a *file* or to a directory that
* is not empty.
*/
isDirEmpty(dirPath) {
if (fs.existsSync(dirPath)) {
if (this.isDir(dirPath)) {
const files = fs.readdirSync(dirPath);
return !files || files.length === 0;
}
return false;
}
return true;
}
/**
* Deletes a file, promisified and in a
* solid way.
*
* You can't delete a root file using this function.
*/
deleteFile(filePath) {
if (!this.isSafeToDelete(filePath)) {
throw new Error("Unsafe file to delete. A file to delete can't be at the root.");
}
return (0, rimraf_1.rimraf)(filePath);
}
/**
* Deletes a directory, promisified and in a
* solid way.
*
* You can't delete a root directory using this function.
*/
async deleteDir(dirPath) {
if (!this.isSafeToDelete(dirPath)) {
throw new Error("Unsafe dir to delete. A dir to delete can't be at the root.");
}
try {
return (0, rimraf_1.rimraf)(dirPath);
}
catch (err) {
// ==========================================
// Try recursively as rimraf may sometimes
// fail in infrequent situations...
// ==========================================
await this.clearDir(dirPath);
return (0, rimraf_1.rimraf)(dirPath);
}
}
/**
* Clears a directory, promisified and in a
* solid way.
*
* You can't clear a root directory using this function.
*/
async clearDir(dirPath) {
if (!this.isSafeToDelete(dirPath)) {
throw new Error("Unsafe dir to clear. A dir to clear can't be at the root.");
}
// NOTE: I had to replace the globby module with fs.readdir, because globby was not
// listing the folders any more!
return new Promise((resolve, reject) => {
fs.readdir(dirPath, async (err, paths) => {
if (err) {
reject(err);
return;
}
for (const path of paths) {
const filePath = pathUtils.join(dirPath, path);
if (fs.lstatSync(filePath).isDirectory()) {
await this.deleteDir(filePath);
}
else {
await this.deleteFile(filePath);
}
}
resolve();
});
});
}
get tscCompilerOptions() {
if (!this.tscCompilerOptionsParams) {
this.tscCompilerOptionsParams = [];
const compilerOptions = tsconfig.load_file_sync(constants_1.constants.appRoot + '/tsconfig.json');
for (const key of Object.keys(compilerOptions)) {
// ==========================================
// TS6064: Options 'plugins', 'composite' can only be specified in 'tsconfig.json' file.
// ==========================================
if (['plugins', 'composite'].includes(key)) {
continue;
}
// ==========================================
// "--forceConsistentCasingInFileNames" sometimes
// causes problems when running tests inside VSCode :
// http://stackissue.com/Microsoft/vscode/lower-case-drive-letter-in-open-new-command-prompt-command-on-windows-9448.html
// ==========================================
if (key === 'forceConsistentCasingInFileNames' && process.env.ide === 'true') {
compilerOptions[key] = false;
}
this.tscCompilerOptionsParams.push('--' + key);
this.tscCompilerOptionsParams.push(compilerOptions[key]);
}
}
return this.tscCompilerOptionsParams;
}
/**
* Runs the "tsc" command on specific files
* using the same options than the ones found
* in the "tsconfig.json" file of the project.
*
* @param files the absolute paths of the files to compile.
* @outdir allows us to redirect the output directory
*/
async tsc(files) {
if (!files) {
return;
}
const cmd = 'node';
const tscCmd = constants_1.constants.findModulePath('node_modules/typescript/lib/tsc.js');
const args = [tscCmd].concat(this.tscCompilerOptions).concat(files);
await this.execPromisified(cmd, args);
}
/**
* Returns a free port.
*/
async findFreePort() {
return await getPort();
}
/**
* Validates if the object is of type Date and
* is valid. Deserializing an invalid string
* to a Date may result in a Date, but which is invalid.
* Then loadash's isDate(d) is not enough to detect
* if the result is valid or not. This function is.
*/
isValidDate(date) {
if (this.isBlank(date)) {
return false;
}
return Object.prototype.toString.call(date) === '[object Date]' && !isNaN(date.getTime());
}
/**
* @deprecated Use `exec()` instead.
*/
execPromisified(command, args, dataHandler = null, useShellOption = false) {
return this.exec(command, args, {
outputHandler: dataHandler,
useShellOption,
disableConsoleOutputs: !dataHandler,
}).then((_val) => {
// nothing, returns void
});
}
/**
* Execute a shell command.
*
* This function is a promisified version of Node's `spawn()`
* with extra options added
* ( https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options ).
*
* @param bin The executable program to call.
*
* @param args The arguments for the program.
*
* @param options.successExitCodes The acceptable codes the
* process must exit with to be considered as a success.
* Defaults to [0].
*
* @param options.outputHandler A function that will receive
* the output of the process (stdOut and stdErr).
* This allows you to capture this output and manipulate it.
* No handler by default.
*
* @param options.disableConsoleOutputs Set to `true` in order
* to disable outputs in the current parent process
* (you can still capture them using a `options.dataHandler`).
* Defaults to `false`.
*
* @param options.stdio See https://nodejs.org/api/child_process.html#child_process_options_stdio
* Defaults to `['inherit', 'pipe', 'pipe']`.
*
* @param options.useShellOption See the "shell" option:
* https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options
* Defaults to `true`.
*
* @returns The exit code
*
* @throws Will fail with a `ExecError` error if the process returns
* a code different than `options.successExitCodes` ("0" by default).
* The exit code would then be available in the generated Error:
* `err.exitCode`.
*/
async exec(bin, args = [], options) {
const optionsClean = options ?? {};
optionsClean.useShellOption = optionsClean.useShellOption ?? true;
optionsClean.successExitCodes = optionsClean.successExitCodes
? (0, lodash_1.isArray)(optionsClean.successExitCodes)
? optionsClean.successExitCodes
: [optionsClean.successExitCodes]
: [0];
optionsClean.stdio = optionsClean.stdio ?? ['inherit', 'pipe', 'pipe'];
optionsClean.disableConsoleOutputs = optionsClean.disableConsoleOutputs ?? false;
if (this.isBlank(bin)) {
throw new ExecError(`The "bin" argument is required`, 1);
}
return new Promise((resolve, reject) => {
const spawnedProcess = (0, child_process_1.spawn)(bin, args, {
detached: false,
stdio: optionsClean.stdio,
shell: optionsClean.useShellOption,
windowsVerbatimArguments: false,
});
spawnedProcess.on('close', (code) => {
const successExitCodes = optionsClean.successExitCodes;
if (!successExitCodes.includes(code)) {
reject(new ExecError(`Expected success codes were "${successExitCodes.toString()}", but the process exited with "${code}".`, code));
}
else {
resolve(code);
}
});
spawnedProcess.stdout.on('data', (output) => {
const outputClean = output ? output.toString() : '';
if (optionsClean.outputHandler) {
optionsClean.outputHandler(outputClean, null);
}
if (!optionsClean.disableConsoleOutputs) {
process.stdout.write(outputClean);
}
});
spawnedProcess.stderr.on('data', (output) => {
const outputClean = output ? output.toString() : '';
if (optionsClean.outputHandler) {
optionsClean.outputHandler(null, outputClean);
}
if (!optionsClean.disableConsoleOutputs) {
process.stderr.write(outputClean);
}
});
});
}
}
exports.Utils = Utils;
/**
* Error thrown when a process launched with `exec()` fails.
*/
// tslint:disable-next-line: max-classes-per-file
class ExecError extends Error {
constructor(message, exitCode) {
super(message);
this.exitCode = exitCode;
}
}
exports.ExecError = ExecError;
function getValueDescription(value) {
return `« ${JSON.stringify(value)} »`;
}
function getValueDescriptionWithType(value) {
const valueType = (0, lodash_1.isObject)(value) ? value.constructor.name : typeof value;
return getValueDescription(value) + ` (${valueType})`;
}
exports.utils = new Utils();
//# sourceMappingURL=utils.js.map