import path from 'path'
import fs from 'fs'
import get from 'lodash-es/get.js'
import now2str from './now2str.mjs'
import haskey from './haskey.mjs'
import isbol from './isbol.mjs'
import isstr from './isstr.mjs'
import isestr from './isestr.mjs'
import iseobj from './iseobj.mjs'
import ispint from './ispint.mjs'
import cint from './cint.mjs'
import j2o from './j2o.mjs'
import o2j from './o2j.mjs'
import fsCreateFolder from './fsCreateFolder.mjs'
import fsIsFolder from './fsIsFolder.mjs'
import fsDeleteFolder from './fsDeleteFolder.mjs'
import fsWatchFolder from './fsWatchFolder.mjs'
import evem from './evem.mjs'
import evEmit from './evEmit.mjs'
import getTrace from './getTrace.mjs'
//事件名稱即事件檔案之檔名, 須為單一安全檔名: 不得使寫入目標離開事件資料夾, 不得含跨平台不合法之字元, 不得為Windows裝置名稱, 不得因Windows去掉結尾之.與空白而與他名衝突
let reEvNameBadChar = /[<>:"/\\|?*]/
let reEvNameDevice = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
let evNameMaxBytes = 200
//checkEvName, 回傳事件名稱不合法之原因字串, 合法者回傳空字串
function checkEvName(evName) {
if (typeof evName !== 'string') {
return 'it must be a string'
}
if (evName === '') {
return 'it must not be empty'
}
if (Buffer.byteLength(evName, 'utf8') > evNameMaxBytes) {
return `it must not be longer than ${evNameMaxBytes} bytes`
}
if (reEvNameBadChar.test(evName) || Array.from(evName).some((c) => c.charCodeAt(0) < 32)) {
return 'it must not contain a path separator, a control character or any of < > : " | ? *'
}
if (evName === '.' || evName === '..') {
return 'it must not be . or ..'
}
if (/[. ]$/.test(evName)) {
return 'it must not end with . or a space'
}
if (reEvNameDevice.test(evName)) {
return 'it must not be a Windows device name'
}
return ''
}
/**
* 後端nodejs跨程序事件發布與訂閱,主要為通過檔案內容變更與監測進行傳遞事件
*
* 因使用chokidar,變更至少要3秒才能監測,不適用於頻繁觸發事件之工作
*
* 事件名稱即事件檔案在事件資料夾下之檔名,故須為單一安全檔名(非空字串、不超過200位元組、不含路徑分隔字元與<>:"|?*及控制字元、不為.或..、不以.或空白結尾、不為Windows裝置名稱如CON與NUL),含.之名稱(如'user.login')原樣送達;不分大小寫之檔案系統上大小寫不同之名稱視為同一事件檔案
*
* Unit Test: {@link https://github.com/yuda-lyu/wsemi/blob/master/test/fsEvem.test.mjs Github}
* @memberOf wsemi
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {String} [opt.fd='./_evps'] 輸入建置事件檔案所在資料夾路徑字串,預設'./_evps'
* @param {Boolean} [opt.polling=false] 輸入是否使用輪循布林值,代表chokidar的usePolling,預設為false
* @param {Integer} [opt.timeInterval=100] 輸入當polling為true時偵測檔案變更間隔時間整數,代表chokidar開啟polling時的interval,單位為毫秒ms,預設為100
* @param {Integer} [opt.timeBinaryInterval=300] 輸入當polling為true時偵測二進位檔案變更間隔時間整數,代表chokidar開啟polling時的binaryInterval,單位為毫秒ms,預設為300
* @param {Function} [opt.onTrace=null] 輸入觀察回調函數,於內部事件發生當下同步呼叫,傳入一個凍結之事件物件{ type, ... },供測試以事件(而非等待時間)同步與診斷;type為'write'時為emit已寫出事件檔案(含evName為事件名稱,即檔名),'ready'時為內部監看事件檔案資料夾之watcher已就緒(初始掃描完成,此後寫入之事件檔案才經穩定期後確實被接收;僅傳了onTrace才監聽,不影響原行為,且不轉傳內部fsWatchFolder之事件流),'skip'時為監看到檔案事件但略過(含reason為略過原因,'kind'代表事件種類非add與change如資料夾本身之addDir或事件檔之unlink,'content'代表檔案內容無法解析為非空物件;kind為監看事件之種類;evName為由檔名推得之事件名稱),'emit'時為即將對本程序之監聽器派發一個事件(含evName為事件名稱;不含事件內容),'clear'時為clear被呼叫(僅首次);clear後不再通知;回調不帶接收者呼叫,其拋錯與回傳值皆忽略,不影響事件傳遞,預設null
* @returns {Object} 回傳事件物件,包含on、emit、clear函數,on可進行監聽指定事件,emit為跨程序發布事件(寫入事件檔案後由各程序之watcher接收再於本地派發),clear為停止全部監聽,不須輸入。事件物件為原生EventEmitter(eventemitter3)。本模組於派發處以try攔截監聽器之同步拋錯故其不會使行程崩潰;惟依EventEmitter規範,同一次派發中先拋錯之監聽器會中止該次派發,其後之監聽器不再被呼叫。async監聽器之reject不被攔截(規範上emit不觀察監聽器回傳值),須由監聽器自行處理,否則為unhandledRejection,監聽器出錯會改於本程序派發error事件{ fun: 'listener', name, msg, args }(僅本地不寫檔廣播),無error監聽者則console.error;故事件名稱請避免使用'error'。emit之事件名稱不合法(見上,如非字串、空字串、含路徑分隔字元、'..')時不寫出任何檔案並同步拋出Error物件,寫入事件檔案失敗(如資料夾無法寫入)時亦同步拋出Error物件,呼叫端須自行try
* @example
* need test in nodejs.
*
* import fsDeleteFolder from './src/fsDeleteFolder.mjs'
* import fsEvem from './src/fsEvem.mjs'
*
* let test = async () => {
* return new Promise((resolve, reject) => {
* let ms = []
*
* fsDeleteFolder('./_evps')
*
* let ev = fsEvem()
*
* ev.on('conut', (msg) => {
* console.log('conut', msg)
* ms.push(msg)
* })
*
* let n = 0
* let t = setInterval(() => {
* n++
* ev.emit('conut', n)
* if (n >= 3) {
* clearTimeout(t)
* }
* }, 3000)
*
* setTimeout(() => {
* ev.clear()
* console.log('ms', ms)
* resolve(ms)
* }, 12000)
*
* })
* }
* await test()
* .catch((err) => {
* console.log(err)
* })
* // conut 1
* // conut 2
* // conut 3
* // ms [ 1, 2, 3 ]
*
*/
function fsEvem(opt = {}) {
//fd
let fd = get(opt, 'fd')
if (!isestr(fd)) {
fd = './_evps'
}
//polling
let polling = get(opt, 'polling')
if (!isbol(polling)) {
polling = false
}
//timeInterval
let timeInterval = get(opt, 'timeInterval')
if (!ispint(timeInterval)) {
timeInterval = 100
}
timeInterval = cint(timeInterval)
//timeBinaryInterval
let timeBinaryInterval = get(opt, 'timeBinaryInterval')
if (!ispint(timeBinaryInterval)) {
timeBinaryInterval = 300
}
timeBinaryInterval = cint(timeBinaryInterval)
//keySys
let keySys = get(opt, 'keySys')
if (!isestr(keySys)) {
keySys = '[::pkg_content::]'
}
//trace, 觀察回調(opt.onTrace), 未給時為null
let trace = getTrace(opt)
//cleared, 是否已clear, 供trace於clear後不再通知
let cleared = false
//check
if (!fsIsFolder(fd)) {
fsCreateFolder(fd)
}
//writeEventFile
let writeEventFile = (evName, msg) => {
// console.log('writeEventFile', evName, msg)
//check, 事件名稱須為單一安全檔名, 不合者不寫出任何檔案
let why = checkEvName(evName)
if (why !== '') {
let nm = typeof evName === 'string' ? JSON.stringify(evName.slice(0, 40)) : typeof evName
throw new Error(`fsEvem: invalid event name ${nm}: ${why}`)
}
//fp, 解析後之位置須恰在事件資料夾下且檔名即事件名稱(上方檢查之保險)
let fdAbs = path.resolve(fd)
let fp = path.resolve(fdAbs, evName)
if (path.dirname(fp) !== fdAbs || path.basename(fp) !== evName) {
throw new Error(`fsEvem: invalid event name ${JSON.stringify(evName.slice(0, 40))}: it must be a single file name inside the event folder`)
}
// console.log('watchEmit fp ', fp)
//check
if (isstr(msg)) {
//不用處理
}
else {
msg = o2j(msg)
}
//writeFileSync
fs.writeFileSync(fp, msg, 'utf8')
// console.log('writeFileSync', fp, msg)
//trace
if (trace && !cleared) {
trace('write', { evName })
}
}
//optWatch
let optWatch = {
polling,
timeInterval,
timeBinaryInterval,
}
if (trace) {
//僅轉為本函數之ready通知, 不轉傳內部fsWatchFolder之事件流
optWatch.onTrace = (e) => {
if (e.type === 'ready' && !cleared) {
trace('ready', {})
}
}
}
//evl
let evl = fsWatchFolder(fd, optWatch)
//change
evl.on('change', (msg) => {
// console.log('fsWatchFolder change', msg.type, msg.fp)
//check
if (msg.type !== 'add' && msg.type !== 'change') {
if (trace && !cleared) {
trace('skip', { reason: 'kind', kind: msg.type, evName: path.basename(msg.fp) })
}
return
}
// console.log('msg.type', msg.type)
//evName, 事件名稱即檔名(含.之名稱原樣, 不去掉最後一段)
let evName = path.basename(msg.fp)
// console.log('evName', evName)
//content
let content = ''
try {
content = fs.readFileSync(msg.fp, 'utf8')
content = j2o(content)
}
catch (err) {}
// console.log(evName, content)
//check
if (!iseobj(content)) {
if (trace && !cleared) {
trace('skip', { reason: 'content', kind: msg.type, evName })
}
return
}
//emit
ev.emit(evName, { [keySys]: content })
})
//clear
let clear = () => {
let first = !cleared
cleared = true
if (trace && first) {
trace('clear', {})
}
evl.clear()
fsDeleteFolder(fd)
}
//ev
let ev = evem()
let emt = ev.emit
ev.emit = function (evName, msg) { //攔截emit與額外處理, 因使用this記得須維持使用funtion
// console.log('攔截ev.emit', evName, msg)
let b = false
if (iseobj(msg)) {
b = haskey(msg, keySys)
}
if (b) {
// console.log('攔截回call', evName, msg)
let value = get(msg, keySys, null)
let pkg = get(value, 'pkg', '')
let time = get(value, 'time', '')
//trace, 即將對本程序之監聽器派發
if (trace && !cleared) {
trace('emit', { evName: String(evName) })
}
//本地派發, 事件源於fsWatchFolder之change回呼故監聽器同步拋錯須於此攔截
// funEmit須指定為原生emt: 通報之error若走覆寫後之ev.emit會被寫成事件檔而廣播至各程序
let self = this
return evEmit(ev, evName, [pkg, time], {
tag: 'fsEvem',
funEmit: (nm, ...a) => {
return emt.call(self, nm, ...a)
},
})
}
else {
// console.log('攔截不回call改寫檔', evName, msg)
writeEventFile(evName, {
pkg: msg,
time: now2str(),
})
}
}
//save
ev.clear = clear
return ev
}
export default fsEvem