UNPKG

feri

Version:

An easy to use build tool for web files.

747 lines (583 loc) 26.8 kB
'use strict' //---------------- // Includes: Self //---------------- const color = require('./color.js') const shared = require('./2 - shared.js') const config = require('./3 - config.js') const functions = require('./4 - functions.js') const build = require('./6 - build.js') //---------- // Includes //---------- const events = require('events') // ~ 1 ms const { mkdirp } = require('mkdirp') // ~ 3 ms const path = require('path') // ~ 1 ms //--------------------- // Includes: Lazy Load //--------------------- let chokidar // require('chokidar') // ~ 22 ms let WebSocket // require('ws') // ~ 27 ms //----------- // Variables //----------- let chokidarSource = '' // will become a chokidar object when watching the source folder let chokidarDest = '' // will become a chokidar object when watching the destination folder let chokidarSourceFiles = '' // string or array of source files being watched let chokidarDestFiles = '' // string or array of destination files being watched let extensionServer = '' // will become a WebSocket object if config.option.extensions is enabled let extensionServerTimer = '' // will become a setInterval object if config.option.extensions is enabled let recentFiles = {} // keep track of files that have changed too recently const watch = { 'emitterDest' : new events.EventEmitter(), 'emitterSource': new events.EventEmitter() } //------------------- // Private Functions //------------------- const lazyLoadChokidar = function watch_lazyLoadChokidar() { if (typeof chokidar === 'undefined') { chokidar = require('chokidar') } } // lazyLoadChokidar const lazyLoadWebSocket = function watch_lazyLoadWebSocket() { if (typeof WebSocket === 'undefined') { WebSocket = require('ws') } } // lazyLoadWebSocket //----------- // Functions //----------- watch.buildOne = async function watch_buildOne(fileName) { /* Figure out which files should be processed after receiving an add or change event from the source directory watcher. @param {String} fileName File path like '/source/js/combined.js' @return {Promise} */ // chill for a bit to let things settle await functions.wait(100) // since rename events can be chaotic, does the source file or folder still exist? let fileExists = await functions.fileExists(fileName) if (fileExists === false) { return 'early' } let ext = functions.fileExtension(fileName) let files = [] const isIncludePrefixFile = path.basename(fileName).substr(0, config.includePrefix.length) === config.includePrefix if (isIncludePrefixFile && config.includePrefix !== '') { if (config.includeFileTypes.indexOf(ext) >= 0) { // included file could be in any of this type of file so check them all files = await functions.findFiles(config.path.source + "/**/*." + ext) } } else { // not an include prefixed file files.push(fileName) // this file should be built } if (config.fileType.concat.enabled) { if (ext === 'concat') { if (isIncludePrefixFile && config.includePrefix !== '') { // concat files that are also _ prefixed include files will not trigger a rebuild of their parent concat file when modified // only a modification of the parent concat file or the modification of any non-concat included files would trigger a rebuild // in other words, avoid creating files like _edgeCase.js.concat functions.log(color.red(shared.language.display('error.concatInclude'))) functions.log(color.gray('https://github.com/nightmode/feri/blob/main/docs/extension-specific-info.md#twilight-zone') + '\n') } ext = functions.fileExtension(functions.removeExt(fileName)) } // .concat files can concat almost anything so check all fileName.ext.concat files const possibleFiles = await functions.findFiles(config.path.source + '/**/*.' + ext + '.concat') if (possibleFiles.length > 0) { for (const x in possibleFiles) { let data = await functions.readFile(possibleFiles[x]) let includeFiles = await functions.includePathsConcat(data, possibleFiles[x]) let concatMeta = await functions.concatMetaRead(possibleFiles[x]) if (concatMeta.toString() !== includeFiles.toString()) { files.push(possibleFiles[x]) } else if (includeFiles.indexOf(fileName) >= 0) { files.push(possibleFiles[x]) } } } } // config.fileType.concat.enabled if (config.fileType.jss.enabled) { // .jss files can encapsulate almost anything so check all .jss files const possibleFiles = await functions.findFiles(config.path.source + '/**/*.jss') if (possibleFiles.length > 0) { for (const x in possibleFiles) { let data = await functions.readFile(possibleFiles[x]) let includeFiles = await functions.includePathsJss(data, possibleFiles[x]) if (includeFiles.indexOf(fileName) >= 0) { files.push(possibleFiles[x]) } } } } // config.fileType.jss.enabled if (files.length > 0) { if (config.includePrefix !== '') { files = files.filter(function(y) { // filter out any _ prefixed includes return path.basename(y).substr(0, config.includePrefix.length) !== config.includePrefix }) } // if if (files.includes(fileName)) { // things can be weird during rename events // does the source file or folder still exist? fileExists = await functions.fileExists(fileName) if (fileExists === false) { return 'late but not too late' } // delete the dest file to ensure a new file is built with the correct case await functions.removeDest(functions.sourceToDest(fileName), false, false) } return build.processBuild(files, true) } } // buildOne watch.checkExtensionClients = function watch_checkExtensionClients() { /* Ping clients to make sure they are still connected. Terminate clients which have not responded to three or more pings. */ extensionServer.clients.forEach(function each(client) { if (client.readyState === WebSocket.OPEN) { if (client._pingAttempt >= 3) { // disconnected client client.terminate() } else { client._pingAttempt += 1 client.send('ping') } } }) } // checkExtensionClients watch.extensionServer = async function watch_extensionServer() { /* Run an extension server for clients. @return {Promise} */ if (config.option.extensions) { lazyLoadWebSocket() // stop the extension server only await watch.stop(false, false, true) await new Promise(function(resolve, reject) { extensionServer = new WebSocket.Server({ port: config.extension.port }) //------------ // Connection //------------ extensionServer.on('connection', function connection(server) { server._pingAttempt = 0 server.on('message', function incoming(buffer) { const message = buffer.toString() if (message === 'ping') { // reply with pong server.send('pong') } else if (message === 'pong') { // client responded to a ping so reset _pingAttempt server._pingAttempt = 0 } }) // send the default document once server.send(JSON.stringify({ defaultDocument: config.extension.defaultDocument })) }) // connection //------- // Error //------- extensionServer.on('error', function(error) { reject(error) }) // error //----------- // Listening //----------- extensionServer.on('listening', function listening() { // check clients for dropped connections every 10 seconds extensionServerTimer = setInterval(watch.checkExtensionClients, 10000) // extension server is running functions.log(color.gray(shared.language.display('message.listeningOnPort').replace('{software}', 'Extension server').replace('{port}', config.extension.port))) resolve() }) // listening }) // await new Promise } // if } // extensionServer watch.notTooRecent = function watch_notTooRecent(file) { /* Suppress subsequent file change notifications if they happen within 300 ms of a previous event. @param {String} file File path like '/path/readme.txt' @return {Boolean} True if a file was not active recently. */ let time = new Date().getTime() let expireTime = time - 300 // clean out old entries in recentFiles for (let x in recentFiles) { if (recentFiles[x] < expireTime) { // remove this entry delete recentFiles[x] } } if (recentFiles.hasOwnProperty(file)) { return false } else { // add entry and return true as in this file was not active recently recentFiles[file] = time return true } } // notTooRecent watch.processWatch = async function watch_processWatch(sourceFiles, destFiles) { /* Watch the source folder. Optionally watch the destination folder and start an extension server. @param {String,Object} [sourceFiles] Optional. Glob search string for watching source files like '*.html' or array of full paths like ['/source/about.html', '/source/index.html'] @param {String,Object} [destFiles] Optional. Glob search string for watching destination files like '*.css' or array of full paths like ['/dest/fonts.css', '/dest/grid.css'] @return {Promise} */ if (config.option.watch) { // start watch timer shared.stats.timeTo.watch = functions.sharedStatsTimeTo(shared.stats.timeTo.watch) let configPathsAreGood = functions.configPathsAreGood() if (configPathsAreGood !== true) { throw new Error(configPathsAreGood) } let exists = await functions.fileExists(config.path.source) if (exists === false) { throw new Error(shared.language.display('error.missingSourceDirectory')) } functions.log(color.gray('\n' + shared.language.display('words.watch') + '\n'), false) await watch.watchSource(sourceFiles) if (config.option.extensions) { await watch.watchDest(destFiles) } // if if (config.option.extensions) { await watch.extensionServer() } shared.stats.timeTo.watch = functions.sharedStatsTimeTo(shared.stats.timeTo.watch) } } // processWatch watch.removeOne = async function watch_removeOne(fileName) { /* Figure out if file or folder should be removed after an unlink or unlinkdir event from the source directory watcher. @param {String} fileName File path like '/source/js/combined.js' @return {Promise} */ // chill for a bit to let things settle await functions.wait(100) const destFile = functions.sourceToDest(fileName) const sourceFileExists = await functions.fileExists(fileName) const destFileExists = await functions.fileExists(destFile) if (sourceFileExists === true || destFileExists === false) { return 'early' } await functions.removeDest(destFile, false) } // removeOne watch.stop = function watch_stop(stopSource, stopDest, stopExtension) { /* Stop watching the source and/or destination folders. Optionally stop the extensions server. @param {Boolean} [stopSource] Optional and defaults to true. If true, stop watching the source folder. @param {Boolean} [stopDest] Optional and defaults to true. If true, stop watching the destination folder. @param {Boolean} [stopExtension] Optional and defaults to true. If true, stop the extension server. @return {Promise} */ stopSource = typeof stopSource === 'boolean' ? stopSource : true stopDest = typeof stopDest === 'boolean' ? stopDest : true stopExtension = typeof stopExtension === 'boolean' ? stopExtension : true return new Promise(function(resolve, reject) { if (stopSource) { if (typeof chokidarSource === 'object') { // clean up previous watcher chokidarSource.close() // remove all listeners chokidarSource.unwatch(chokidarSourceFiles) } } if (stopDest) { if (typeof chokidarDest === 'object') { // clean up previous watcher chokidarDest.close() // remove all listeners chokidarDest.unwatch(chokidarDestFiles) } } if (stopExtension) { clearInterval(extensionServerTimer) // no need to check for disconnected clients anymore if (typeof extensionServer === 'object') { extensionServer.close() for (const client of extensionServer.clients) { client.terminate() } // for } // if } // if resolve() }) } // stop watch.updateExtensionServer = async function watch_updateExtensionServer(now) { /* Update the extension server with a list of changed files. @param {Boolean} [now] Optional and defaults to false. True means we have already waited 300 ms for events to settle. @return {Promise} */ now = now || false if (!now) { // will proceed 300 ms from now in order for things to settle clearTimeout(shared.extension.calmTimer) shared.extension.calmTimer = setTimeout(function() { watch.updateExtensionServer(true) }, 300) } else { if (shared.extension.changedFiles.length > 0) { let fileList = '{"files": ' + JSON.stringify(shared.extension.changedFiles) + '}' shared.extension.changedFiles = [] extensionServer.clients.forEach(function each(client) { if (client.readyState === WebSocket.OPEN) { client.send(fileList) } }) functions.log(color.gray(shared.language.display('message.watchUpdated').replace('{software}', 'Extension server') + '\n')) } } } // updateExtensionServer watch.watchDest = async function watch_watchDest(files) { /* Watch the destination directory for changes in order to update the extensions server as needed. @param {String,Object} [files] Optional. Glob search string for watching destination files like '*.css' or array of full paths like ['/dest/fonts.css', '/dest/grid.css'] @return {Promise} */ lazyLoadChokidar() let filesType = typeof files if (filesType === 'object') { // we already have a specified list to work from } else { if (filesType === 'string') { // string should be a glob files = files.replace(config.path.dest, '') } else { // files is undefined if (config.glob.watch.dest !== '') { files = config.glob.watch.dest } else { files = '' } } if (files.charAt(0) === '/' || files.charAt(0) === '\\') { files = files.substring(1) } // chokidar will be happier without backslashes files = config.path.dest.replace(/\\/g, '/') + '/' + files } await watch.stop(false, true, false) // stop watching dest let readyCalled = false await new Promise(function(resolve, reject) { chokidarDestFiles = files chokidarDest = chokidar.watch([], config.thirdParty.chokidar) chokidarDest .on('add', function(file) { if (!shared.suppressWatchEvents) { let ext = functions.fileExtension(file) if (config.extension.fileTypes.indexOf(ext) >= 0) { functions.log(color.gray(functions.trimDest(file).replace(/\\/g, '/') + ' ' + shared.language.display('words.add'))) // emit an event watch.emitterDest.emit('add', file) shared.extension.changedFiles.push(file.replace(config.path.dest + '/', '')) watch.updateExtensionServer() } } }) .on('change', function(file) { if (!shared.suppressWatchEvents) { let ext = functions.fileExtension(file) if (config.extension.fileTypes.indexOf(ext) >= 0) { functions.log(color.gray(functions.trimDest(file).replace(/\\/g, '/') + ' ' + shared.language.display('words.change'))) // emit an event watch.emitterDest.emit('change', file) shared.extension.changedFiles.push(file.replace(config.path.dest + '/', '')) watch.updateExtensionServer() } } }) .on('error', function(error) { if (error.code === 'EPERM' && shared.platform === 'win32') { // ignore eperm errors on windows which can happen when deleting folders return 'early' } functions.log(shared.language.display('error.watchingDest')) functions.logError(error) // emit an event watch.emitterDest.emit('error', error) reject() // a promise can only be resolved or rejected once so if this gets called more than once it will be harmless }) .on('ready', function() { // chokidar can return more than one ready event if given a file array with a string and glob like ['file.js', *.html'] // in the case of multiple ready events, emit our own ready event only once if (readyCalled === false) { readyCalled = true functions.log(color.gray(shared.language.display('message.watchingDirectory').replace('{directory}', '/' + path.basename(config.path.dest)))) watch.emitterDest.emit('ready') resolve() } }) chokidarDest.add(files) }) } // watchDest watch.watchSource = async function watch_watchSource(files) { /* Watch source directory for changes and kick off the appropriate response tasks as needed. @param {String,Object} [files] Optional. Glob search string for watching source files like '*.html' or array of full paths like ['/source/about.html', '/source/index.html'] @return {Promise} */ lazyLoadChokidar() let filesType = typeof files if (filesType === 'object') { // we already have a specified list to work from } else { if (filesType === 'string') { // string should be a glob files = files.replace(config.path.source, '') } else { // files is undefined if (config.glob.watch.source !== '') { files = config.glob.watch.source } else { files = '' } } if (files.charAt(0) === '/' || files.charAt(0) === '\\') { files = files.substring(1) } // chokidar will be happier without backslashes files = config.path.source.replace(/\\/g, '/') + '/' + files } await watch.stop(true, false, false) // stop watching source let readyCalled = false await new Promise(function(resolve, reject) { chokidarSourceFiles = files chokidarSource = chokidar.watch([], config.thirdParty.chokidar) chokidarSource .on('add', function(file) { if (!shared.suppressWatchEvents) { functions.log(color.gray(functions.trimSource(file).replace(/\\/g, '/') + ' ' + shared.language.display('words.add'))) // emit an event watch.emitterSource.emit('add', file) watch.workQueueAdd('source', 'add', file) } }) .on('addDir', function(file) { if (!shared.suppressWatchEvents) { functions.log(color.gray(functions.trimSource(file).replace(/\\/g, '/') + ' ' + shared.language.display('words.addDirectory'))) // emit an event watch.emitterSource.emit('add directory', file) watch.workQueueAdd('source', 'adddir', file) } }) .on('change', function(file) { if (!shared.suppressWatchEvents) { if (watch.notTooRecent(file)) { functions.log(color.gray(functions.trimSource(file).replace(/\\/g, '/') + ' ' + shared.language.display('words.change'))) // emit an event watch.emitterSource.emit('change', file) watch.workQueueAdd('source', 'change', file) } else { if (config.option.debug) { functions.log(color.yellow(shared.language.display('message.fileChangedTooRecently').replace('{file}', functions.trimSource(file).replace(/\\/g, '/')))) } } } }) .on('unlink', function(file) { if (!shared.suppressWatchEvents) { functions.log(color.gray(functions.trimSource(file).replace(/\\/g, '/') + ' ' + shared.language.display('words.removed'))) // emit an event watch.emitterSource.emit('removed', file) watch.workQueueAdd('source', 'unlink', file) } }) .on('unlinkDir', function(file) { if (!shared.suppressWatchEvents) { functions.log(color.gray(functions.trimSource(file).replace(/\\/g, '/') + ' ' + shared.language.display('words.removedDirectory'))) // emit an event watch.emitterSource.emit('removed directory', file) watch.workQueueAdd('source', 'unlinkdir', file) } }) .on('error', function(error) { if (error.code === 'EPERM' && shared.platform === 'win32') { // ignore eperm errors on windows which can happen when deleting folders return 'early' } functions.log(shared.language.display('error.watchingSource')) functions.logError(error) // emit an event watch.emitterSource.emit('error', error) reject() // a promise can only be resolved or rejected once so if this gets called more than once it will be harmless }) .on('ready', function() { // chokidar can return more than one ready event if given a file array with a string and glob like ['file.js', *.html'] // in the case of multiple ready events, emit our own ready event only once if (readyCalled === false) { readyCalled = true functions.log(color.gray(shared.language.display('message.watchingDirectory').replace('{directory}', '/' + path.basename(config.path.source)))) recentFiles = {} // reset recentFiles in case any changes happened while we were loading watch.emitterSource.emit('ready') resolve() } }) chokidarSource.add(files) }) } // watchSource watch.workQueueAdd = function watch_workQueueAdd(location, task, path) { /* Add an event triggered task to the shared.watch.workQueue array. @param {String} location A string like 'source' or 'dest'. @param {String} task An event triggered task string like 'add', 'change', and so on. @param {String} path A string with the full path to a file or folder. */ shared.watch.workQueue.push({ location: location, task: task, path: path }) watch.workQueueProcess() } // workQueueAdd watch.workQueueProcess = async function watch_workQueueProcess() { /* Process the shared.watch.workQueue array and run tasks one at a time to match the order of events. @return {Promise} */ if (shared.watch.working || shared.watch.workQueue.length === 0) { // an instance of this function is already working the queue or there is nothing to do return 'early' } shared.watch.working = true // so subsequent calls to this function know we are working the queue do { let work = shared.watch.workQueue.shift() // removes the first item from the array try { if (work.location === 'source') { switch (work.task) { case 'add': await watch.buildOne(work.path) break case 'adddir': await mkdirp(functions.sourceToDest(work.path)) break case 'change': await watch.buildOne(work.path) break case 'unlink': await watch.removeOne(work.path) break case 'unlinkdir': await watch.removeOne(work.path) break default: functions.log('watch.workQueueProcess -> unknown source work task "' + work.task + '"') break } // switch } else { functions.log('watch.workQueueProcess -> unknown work location "' + work.location + '"') } } catch (error) { if (error !== 'done') { functions.log(color.red('watch.workQueueProcess -> try catch error')) functions.logError(error) } } } while (shared.watch.workQueue.length > 0) shared.watch.working = false } // workQueueProcess //--------- // Exports //--------- module.exports = watch