node-red-contrib-uibuilder
Version:
Easily create data-driven web UI's for Node-RED using any (or no) front-end library.
645 lines (555 loc) • 25.5 kB
JavaScript
/* eslint-disable class-methods-use-this */
/** Manage uibuilder server files
*
* Copyright (c) 2023-2024 Julian Knight (Totally Information)
* https://it.knightnet.org.uk, https://github.com/TotallyInformation/node-red-contrib-uibuilder
*
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// REFERENCES
// https://nodejs.org/docs/latest-v14.x/api/fs.html
// https://github.com/jprichardson/node-fs-extra
// https://github.com/jprichardson/node-jsonfile
// https://github.com/isaacs/node-graceful-fs
/** --- Type Defs ---
* @typedef {import('../../typedefs.js').runtimeRED} runtimeRED
* @typedef {import('../../typedefs.js').uibNode} uibNode
* @typedef {import('../../typedefs.js').uibConfig} uibConfig
* @typedef {import('../../typedefs.js').uibPackageJson} uibPackageJson
*/
const { join, relative, normalize } = require('node:path')
// const fsextra = require('fs-extra')
// Async
const fs = require('node:fs/promises')
// Sync
const { accessSync, cpSync, constants: fsConstants, existsSync, mkdirSync, readFileSync } = require('node:fs')
// TODO Remove in future?
const fg = require('fast-glob')
// WARNING: Take care not to end up with circular requires. e.g. libs/socket.js cannot be required here
// ! TODO: Move other file-handling functions into this class
// ! In readiness for move to mono-repo (need node.js v16+ as a base)
let log
class UibFs {
//#region --- Class vars ---
/** PRIVATE Flag to indicate whether setup() has been run (ignore the false eslint error)
* @type {boolean}
*/
#isConfigured = false
// #logUndefinedError = new Error('fs: this.log is undefined')
// #uibUndefinedError = new Error('fs: this.uib is undefined')
// #rootFldrNullError = new Error('fs: this.uib.rootFolder is null')
/** @type {Array<string>} Updated by updateMergedPackageList which is called first in setup and then in various updates */
mergedPkgMasterList = []
/** @type {string} The name of the package.json file 'package.json' */
packageJson = 'package.json'
/** @type {uibPackageJson|null} The uibRoot package.json contents */
uibPackageJson
/** @type {string} Get npm's global install location */
globalPrefix // set in constructor
//#endregion --- ----- ---
// constructor() {} // ---- End of constructor ---- //
//#region ---- Utility methods ----
/** Configure this class with uibuilder module specifics
* @param {uibConfig} uib uibuilder module-level configuration
*/
setup( uib ) {
if ( !uib ) throw new Error('[uibuilder:UibFs:setup] Called without required uib parameter or uib is undefined.')
if ( uib.RED === null ) throw new Error('[uibuilder:UibFs:setup] uib.RED is null')
// Prevent setup from being called more than once
if ( this.#isConfigured === true ) {
uib.RED.log.warn('⚠️[uibuilder:UibFs:setup] Setup has already been called, it cannot be called again.')
return
}
this.RED = uib.RED
this.uib = uib
this.log = log = uib.RED.log
this.log.trace('[uibuilder:fs.js:setup] Setup completed', this.uib)
} // ---- End of setup ---- //
/** returns true if the filename contains / or \ else false
* @param {string} fname filename to test
* @returns {boolean} True if fname contains / or \
*/
hasFolder(fname) {
return /[/|\\]/.test(fname)
}
/** Throw an error if a path string contains folder traversal `..`
* @param {string} fname Path to test
* @param {string} note Optional text to add to error to indicate source
*/
throwOnFolderEscape(fname, note) {
if (fname.includes('..')) {
throw new Error(`Path includes '..'. Folder traversal not permitted. '${fname}' [uibuilder:UibFs:throwOnFolderEscape] ${note}`)
}
}
/** Walks through a folder and sub-folders returning list of files
* @param {string} dir Folder name to start the walk
* @param {string} ftype File extension to filter on, e.g. '.html'
* @returns {Promise<string[]>} On each call, returns the next found file name
*/
async walk(dir, ftype) {
let files = await fs.readdir(dir)
// @ts-ignore
files = await Promise.all(files.map(async file => {
const filePath = join(dir, file)
const stats = await fs.stat(filePath)
if (stats.isDirectory()) {
return this.walk(filePath, ftype)
} else if (stats.isFile() && file.endsWith(ftype)) {
return filePath
}
}))
// Filter out undefined entries before concatenating
return files.filter(Boolean).reduce((all, folderContents) => all.concat(folderContents), [])
} // -- End of walk -- //
//#endregion ---- ---- ----
get uibRootFolder() {
return this.uib.rootFolder
}
//#region ---- Async Methods ----
// async getAllInstanceUrls
/** ASYNC Folder or File copy - Will THROW on error
* @param {Array<string>|string} src Source. Single string or array of strings that will be `join`d
* @param {Array<string>|string} dest Destination. Single string or array of strings that will be `join`d
* @param {boolean} [overwrite] Optional. Default=false. Overwrite on copy or fail if dest exists
* @param {import('node:fs').CopyOptions} [opts] Optional.
* @returns {Promise<boolean>} True if copy succeeded else error thrown
*/
async copy(src, dest, overwrite, opts) {
if (!overwrite) overwrite = false
if (!opts) {
opts = {
force: overwrite === true,
preserveTimestamps: true,
}
}
// @ts-ignore
if (Array.isArray(src)) src = join(src)
// @ts-ignore
if (Array.isArray(dest)) dest = join(dest)
try {
await fs.cp(src, dest, opts)
} catch (e) {
throw new Error(`Could not copy '${src}' to '${dest}'. ${e.message}`)
}
return true
}
/** Returns the create/amend timestamps (as JS Date objects) & file size
* @param {string} fname File name to examine
* @returns {Promise< {created:Date,modified:Date,size:number,[pageName:string]} | {error:string,[originalError:string]} >} File stats
*/
async getFileMeta(fname) {
if (!fname) return { error: 'No file provided' }
let stat
try {
const fstat = await fs.stat(fname)
stat = {
created: fstat.birthtime,
modified: fstat.mtime,
size: fstat.size,
}
} catch (e) {
stat = {
error: 'Could not get file metadata',
originalError: e.message // take care not to leak this to end users
}
}
return stat
}
/** Return all of the *.html files from the served folder for a specific instance
* NOTE: Only call this after all nodes are loaded into the Node-RED runtime
* @param {string} url uibuilder instance url (name)
* @returns {Promise<string[]>} List of discovered html files with their folder if needed
*/
async getInstanceLiveHtmlFiles(url) {
if ( !this.uib ) throw new Error('Called without required uib parameter or uib is undefined [uibuilder:UibFs:writeInstanceFile]')
if ( this.uib.RED === null ) throw new Error('uib.RED is null [uibuilder:UibFs:writeInstanceFile] ')
const RED = this.uib.RED
// Get the node id of the instance
let node
for (const [key, value] of Object.entries(this.uib.instances)) {
if (value === url) node = RED.nodes.getNode(key)
}
// Make sure we got a node
if (!node) {
RED.log.error(`No node found for url="${url}" - called before all nodes loaded in Node-RED?`)
return []
}
// Get the served folder from the instance node
let folder = ''
const allFiles = []
folder = join(node.customFolder, node.sourceFolder)
// Get all *.html files recursively
for await (const p of await this.walk(folder, '.html')) {
allFiles.push(p.replace(folder, ''))
}
return allFiles
} // -- End of getInstanceLiveHtmlFiles -- //
/** Get a text file from uibuilder's master template folders
* @param {*} template The name of the master template, e.g. "blank" or "esm-blank-client"
* @param {*} fName The name of the file to get (optionally with leading folder using forward-slash separators)
* @returns {Promise<string>} The text contents of the file.
*/
async getTemplateFile(template, fName) {
return await fs.readFile( join(__dirname, '..', '..', 'templates', template, fName), 'utf8')
}
async getUibInstanceRootFolders() {
// const chkInstances = Object.values(uib.instances).includes(params.url)
// const chkFolders = existsSync(join(uib.rootFolder, params.url))
}
// TODO Move degit processing to its own function. Don't need the emitter on uib
/** Replace template in front-end instance folder
* @param {string} url The uib instance URL
* @param {string} template Name of one of the built-in templates including 'blank' and 'external'
* @param {string|undefined} extTemplate Optional external template name to be passed to degit. See degit options for details.
* @param {string} cmd 'replaceTemplate' if called from admin-router:POST, otherwise can be anything descriptive & unique by caller
* @param {object} templateConf Template configuration object
* @param {object} uib uibuilder's master variables
* @param {object} log uibuilder's Log functions (normally points to RED.log)
* @returns {Promise} {statusMessage, status, (json)}
*/
async replaceTemplate(url, template, extTemplate, cmd, templateConf, uib, log) {
const res = {
'statusMessage': 'Something went wrong!',
'status': 500,
'json': undefined,
}
// Load a new template (params url, template, extTemplate)
if ( template === 'external' && ( (!extTemplate) || extTemplate.length === 0) ) {
const statusMsg = `External template selected but no template name provided. template=external, url=${url}, cmd=${cmd}`
log.error(`[uibuilder:fs:replaceTemplate]. ${statusMsg}`)
res.statusMessage = statusMsg
res.status = 500
return res
}
/** Destination folder name */
const fullname = join(uib.rootFolder, url)
if ( extTemplate ) extTemplate = extTemplate.trim()
if ( extTemplate === undefined ) throw new Error('extTemplate is undefined')
// TODO Move degit processing to its own function. Don't need the emitter on uib
// If template="external" & extTemplate not blank - use degit to load
if ( template === 'external' ) {
const degit = require('degit')
uib.degitEmitter = degit(extTemplate, {
cache: false, // Fix for Issue #155 part 3 - degit error
force: true,
verbose: false,
})
uib.degitEmitter.on('info', info => {
log.trace(`[uibuilder:fs:replaceTemplate] Degit: '${extTemplate}' to '${fullname}': ${info.message}`)
})
await uib.degitEmitter.clone(fullname)
// console.log({myclone})
const statusMsg = `Degit successfully copied template '${extTemplate}' to '${fullname}'.`
log.info(`[uibuilder:uiblib:replaceTemplate] ${statusMsg} cmd=${cmd}`)
res.statusMessage = statusMsg
res.status = 200
res.json = /** @type {*} */ ({
'url': url,
'template': template,
'extTemplate': extTemplate,
'cmd': cmd,
})
return res
} else if ( Object.prototype.hasOwnProperty.call(templateConf, template) ) {
// Otherwise, use internal template - copy whole template folder
// const fsOpts = { 'overwrite': true, 'preserveTimestamps': true }
const fsOpts = { 'force': true, 'preserveTimestamps': true, 'recursive': true }
/** Source template folder name */
const srcTemplate = join( uib.masterTemplateFolder, template )
try {
// fsextra.copySync( srcTemplate, fullname, fsOpts )
// NB: fs.cp is still experimental even in node.js v20 - but seems stable since v16
await fs.cp(srcTemplate, fullname, fsOpts)
const statusMsg = `Successfully copied template ${template} to ${url}.`
log.info(`[uibuilder:fs:replaceTemplate] ${statusMsg} cmd=replaceTemplate`)
res.statusMessage = statusMsg
res.status = 200
res.json = /** @type {*} */ ({
'url': url,
'template': template,
'extTemplate': extTemplate,
'cmd': cmd,
})
return res
} catch (err) {
const statusMsg = `Failed to copy template from '${srcTemplate}' to '${fullname}'. url=${url}, cmd=${cmd}, ERR=${err.message}.`
log.error(`[uibuilder:fs:replaceTemplate] ${statusMsg}`, err)
res.statusMessage = statusMsg
res.status = 500
return res
}
} else {
// Shouldn't ever be able to occur - but still :-)
const statusMsg = `Template '${template}' does not exist. url=${url}, cmd=${cmd}.`
log.error(`[uibuilder:fs:replaceTemplate] ${statusMsg}`)
res.statusMessage = statusMsg
res.status = 500
return res
}
} // ----- End of replaceTemplate() ----- //
/** Return list of found files as folder/files and/or urls
* @param {string} uibId The uibuilder node instance id to search
* @param {boolean} live If true, the search root will be limited to the currently live served folder
* @param {[string]} filter A fast-glob search filter array (or string)
* @param {[string]} exclude A fast-glob exclude filter array (or string)
* @param {boolean} urlOut Output will include a url list (relative to the current uib instance root)
* @param {boolean} fullPrefix Output will be prefixed with the full path for both file and url outputs
* @returns {Promise<any>} List of found files
*/
async searchInstance(uibId, live, filter, exclude, urlOut, fullPrefix) {
if ( !this.uib ) throw new Error('Called without required uib parameter or uib is undefined [uibuilder:UibFs:searchInstance]')
if ( this.uib.RED === null ) throw new Error('uib.RED is null [uibuilder:UibFs:searchInstance] ')
const RED = this.uib.RED
// Get node instance paths
const uibnode = RED.nodes.getNode(uibId)
const searchFolder = join(uibnode.customFolder, live === true ? uibnode.sourceFolder : '')
// Defaults to finding everything in the search folder
if (!filter) filter = ['*']
if (!Array.isArray(filter)) filter = [filter]
// Remove dangerous ../ segments from filters
filter.forEach( (f, i) => {
filter[i] = f.replace(/\.\.\//g, '')
})
const opts = {
cwd: searchFolder,
suppressErrors: true,
unique: true,
deep: 10, // restrict to max 10 deep sub-folders (prevent recursion)
// caseSensitiveMatch: true, // default
// dot: false, // default
}
if (exclude) opts.ignore = exclude
const srch = await fg.async(filter, opts)
const isCustom = this.uib.customServer.isCustom
let prefix = ''
if (urlOut) {
if (fullPrefix) {
if (isCustom || (!isCustom && !this.uib.httpRoot)) prefix = `./${uibnode.url}/`
else prefix = `./${this.uib.httpRoot}/${uibnode.url}/`
} else {
prefix = './'
}
}
srch.forEach( (f, i) => {
// If output as urls required, replace `index.html`
if (urlOut) {
f = f.replace('index.html', '')
} else {
if (fullPrefix) {
f = join(searchFolder, f)
} else {
f = relative(searchFolder, join(searchFolder, f))
}
}
srch[i] = `${prefix}${f}`
})
const out = {
results: srch,
config: {
filter: {
searchRoot: searchFolder,
filter: filter,
exclude: exclude,
urlOutput: urlOut,
fullPrefixOutput: fullPrefix,
},
uibuilder: {
rootFolder: normalize(this.uibRootFolder),
},
node: {
id: uibId,
name: uibnode.url,
customFolder: uibnode.customFolder,
liveFolder: uibnode.sourceFolder,
}
},
}
if (!isCustom) {
out.config.uibuilder.httpRoot = this.uib.httpRoot
} else {
out.config.uibuilder.customServer = this.uib.customServer
}
return out
}
// TODO chk params
/** Output a file to an instance folder (async/promise)
* NB: Errors have the fn indicator at the end because this is expected to be a utility fn called from elsewhere
* This is also the reason we throw errors here rather than output error msgs
* @param {string} url The uibuilder instance URL (name)
* @param {string} folder The folder to save to (no '..' allowed)
* @param {string} fname The file name to save (no path allowed)
* @param {*} data The data to save, string or buffer
* @param {boolean} createFolder If true and folder does not exist, will be created. Else returns error
* @param {boolean} reload If true, issue a reload command to all clients connected to the instance
* @returns {Promise} Success if write is successful
*/
async writeInstanceFile(url, folder, fname, data, createFolder = false, reload = false) {
if ( !this.uib ) throw new Error('Called without required uib parameter or uib is undefined [uibuilder:UibFs:writeInstanceFile]')
if ( this.uib.RED === null ) throw new Error('uib.RED is null [uibuilder:UibFs:writeInstanceFile] ')
const log = this.uib.RED.log
// Make sure that the inputs don't include folder traversal
this.throwOnFolderEscape(folder, 'writeInstanceFile, folder')
this.throwOnFolderEscape(folder, 'writeInstanceFile, file')
// Check to see if any folder's on the filename - if so, move to the fullFolder
if (this.hasFolder(fname)) {
const splitFname = fname.split(/[/|\\]/)
fname = splitFname.pop()
folder = join(folder, ...splitFname)
}
const uib = this.uib
// TODO check parameters
// TODO check if uib.rootFolder/url folder exists
const fullFolder = join(uib.rootFolder, url, folder)
// Test if folder exists and can be written to. If not, error unless createFolder flag is true
try {
await fs.access(fullFolder, fs.constants.W_OK)
} catch {
// If createFolder flag set, attempt to create the folder
if (createFolder === true) {
try {
await fs.mkdir(fullFolder, { recursive: true }) // Add mode?
} catch (err) {
throw new Error(`Cannot create folder. ${err.message} [uibuilder:UibFs:writeInstanceFile]`, err)
}
} else {
throw new Error(`Folder does not exist. "${fullFolder}" [uibuilder:UibFs:writeInstanceFile]`)
}
}
const fullname = join(fullFolder, fname)
try {
// https://nodejs.org/docs/latest-v14.x/api/fs.html#fs_fspromises_writefile_file_data_options
await fs.writeFile(fullname, data)
// await fs.outputFile(fullname, data)
} catch (err) {
throw new Error(`File write FAIL. ${err.message} [uibuilder:UibFs:writeInstanceFile]`, err)
}
log.trace(`📗[uibuilder:UibFs:writeInstanceFile] File write SUCCESS. url=${url}, file=${folder}/${fname}`)
} // -- End of writeFile -- //
//#endregion ---- ---- ----
//#region ---- Synchronous methods ----
/** Synchronously try access and error if fail.
* @param {string} path Path to try to access
* @param {'r'|'w'|'rw'|number} mode Modes required to work: r, w or rw
*/
accessSync(path, mode) {
switch (mode) {
case 'r': {
mode = fsConstants.R_OK
break
}
case 'w': {
mode = fsConstants.W_OK
break
}
case 'rw': {
mode = fsConstants.R_OK || fsConstants.W_OK
break
}
default: {
mode = fsConstants.R_OK || fsConstants.W_OK
break
}
}
accessSync(path, mode)
}
/** Synchronous Folder or File copy - Will THROW on error
* @param {Array<string>|string} src Source. Single string or array of strings that will be `join`d
* @param {Array<string>|string} dest Destination. Single string or array of strings that will be `join`d
* @param {boolean} [overwrite] Optional. Default=false. Overwrite on copy or fail if dest exists
* @param {import('node:fs').CopySyncOptions} [opts] Optional.
* @returns {boolean} True if copy succeeded else false
*/
copySync(src, dest, overwrite, opts) {
if (!overwrite) overwrite = false
if (!opts) {
opts = {
force: overwrite === true,
preserveTimestamps: true,
}
}
try {
// @ts-ignore
if (Array.isArray(src)) src = join(...src)
// ts-ignore
if (Array.isArray(dest)) dest = join(...dest)
cpSync(src, dest, opts)
} catch (e) {
log.error(`Could not copy '${src}' to '${dest}'. ${e.message}`, e)
return false
}
return true
}
// ensureFolder({ folder, copyFrom, }) {
// const cpyOpts = { 'preserveTimestamps': true }
// Make sure folder exists, create if not
// Make sure that the folder can be read/write
// If copyFrom not undefined/null/'', copy to folder
// }
/** Does the path exist? Pass 1 or more args which are joined & then checked
* param {string} path FS Path to check
* @returns {boolean} True if path exists
*/
existsSync() {
const p = join(...arguments)
return existsSync(p)
}
/** Return a list of files matching the glob specification
* @param {string} glob The pattern to match - see fast-glob for details
* @returns {string[]} A list of files
*/
fgSync(glob) {
if (!glob) return []
return fg.sync(glob)
}
/** Synchronously create a folder
* @param {string} path The folder to create - creates intermediates only if recursive option is set
* @param {{recursive:boolean}|{recursive:boolean,mode:string|number}|number} [options] Options
* @returns {string|undefined} Returns undefined unless recursive is set in which case the 1st created path is returned
*/
mkdirSync(path, options) {
return mkdirSync(path, options)
}
//#endregion ---- ---- ----
/* TODO
rm
moveSync
copySync
copy
ensureDirSync
*/
//#region ---- async fs-extra replacement methods ----
//#endregion ---- ---- ----
//#region ---- synchronous fs-extra replacement methods ----
/** Read a JSON file and return as a JavaScript object - can use instead of fs-extra
* @throws If reading or parsing fails
* @param {string} file JSON file path/name to read
* @returns {object} The parsed JSON file as an object
*/
readJSONSync(file) {
try {
return JSON.parse(readFileSync(file, 'utf8'))
} catch (e) {
e.message = `${file}: ${e.message}`
throw e
}
}
//#endregion ---- ---- ----
} // ----- End of UibPackages ----- //
/** Singleton model. Only 1 instance of UibWeb should ever exist.
* Use as: `const packageMgt = require('./package-mgt.js')`
*/
// @ts-ignore
const uibFs = new UibFs()
module.exports = uibFs
// EOF