evEmit.mjs

import isfun from './isfun.mjs'


/**
 * 於呼叫端安全派發事件,攔截監聽器之同步拋錯
 *
 * 【為何在呼叫端派發,而非包裝監聽器】
 *
 * EventEmitter之規範語意為「監聽器同步拋錯時由emit外拋至emit之呼叫端」,該行為本身沒有問題。
 * 會出事的是派發位置:於timer或I/O回呼內派發時,那個外拋即成uncaughtException而殺整個行程。
 * 所以要處理的是派發位置,不是emitter之契約 —— 若改為包裝監聽器,就必須維護「包裝函數↔原函數」
 * 之對應表,而該對應表會使on、off、once、listeners之行為偏離規範(事件名型別別名、以異物移除、
 * once自動移除等),封裝層自身反而成為「調用方拿到非預期」之來源。
 *
 * 【為何不以setTimeout脫勾來迴避】
 *
 * setTimeout會開一個「沒有呼叫者」的堆疊,監聽器之同步拋錯於該處即為uncaughtException,
 * 且呼叫端無論如何try都攔不到(此與監聽器是否為async無關,純同步監聽器亦然)。
 * 於呼叫端之堆疊上派發,一個普通的try即可攔下。確實需要脫勾者請改用evEmitDefer,
 * 其內部即以本函數派發,故仍有攔截。
 *
 * 【為何不處理async監聽器之reject】
 *
 * emit依EventEmitter規範丟棄監聽器之回傳值,其rejection無人觀察;nodejs文件亦明載
 * 「使用async函數作為事件處理器有問題」,並將captureRejections設計為opt-in而非預設,
 * 而eventemitter3與瀏覽器端之events polyfill皆未提供該選項。
 * 故監聽器應為同步,非同步之結果請以事件所帶之pm回覆 —— pm即為回覆通道,監聽器不需要第二條。
 *
 * 【settle為何排在通報之前】
 *
 * settle該次派發所帶之pm為唯一「非做不可」者,通報排其後,如此縱使通報那幾行拋錯,
 * 該次流程也已settle,懸置在結構上不可能發生。此順序為明文要求,不得對調。
 *
 * Unit Test: {@link https://github.com/yuda-lyu/wsemi/blob/master/test/evEmit.test.mjs Github}
 * @memberOf wsemi
 * @param {Object} ev 輸入事件物件,為evem所建立之eventemitter3實例
 * @param {String} name 輸入事件名稱字串
 * @param {Array} [args=[]] 輸入事件參數陣列,預設[]
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {Function} [opt.funSettle=null] 輸入監聽器出錯時之收尾函數,傳入(err),用於settle該次派發所帶之pm使流程不懸置。不指定即代表本事件無回覆通道,不得由本函數猜測pm之位置
 * @param {Function} [opt.funEmit=null] 輸入實際派發函數,傳入(name, ...args),預設使用ev.emit。供已覆寫ev.emit之模組指定以原生emit於本程序派發,否則通報之error亦會走覆寫後之路徑
 * @param {String} [opt.tag='evem'] 輸入模組名稱字串,用於console.error之前綴,預設'evem'
 * @returns {Boolean} 回傳emit之結果布林值,監聽器出錯時回傳false
 * @example
 *
 * let ev = evem()
 *
 * ev.on('error', (msg) => {
 *     console.log(msg.fun, msg.name, msg.msg.message)
 *     // => listener ev boom
 * })
 * ev.on('ev', () => {
 *     throw new Error('boom')
 * })
 *
 * let r = evEmit(ev, 'ev', [{ a: 1 }], { tag: 'demo' })
 * console.log(r)
 * // => false
 *
 */
function evEmit(ev, name, args = [], opt = {}) {

    let funSettle = opt.funSettle
    let tag = opt.tag || 'evem'

    //emit, 預設用ev.emit; 已覆寫ev.emit者須指定funEmit, 否則通報之error亦會走覆寫後之路徑
    let emit = (nm, ...a) => {
        return ev.emit(nm, ...a)
    }
    if (isfun(opt.funEmit)) {
        emit = opt.funEmit
    }

    try {
        return emit(name, ...args)
    }
    catch (err) {

        //funSettle, 須排在通報之前, 且其自身拋錯亦須隔離
        //  否則呼叫端(常為timer或watcher之回呼)會收到該拋錯而成uncaughtException, 等於防護於自身破功
        if (isfun(funSettle)) {
            try {
                funSettle(err)
            }
            catch (err2) {
            }
        }

        //通報, 依當下是否有error監聽者決定emit或console.error, 二擇一不重複
        //  對error事件本身之監聽器拋錯者不再重發, 避免無限遞迴
        try {
            if (name !== 'error' && ev.listenerCount('error') > 0) {
                emit('error', { fun: 'listener', name, msg: err, args })
            }
            else {
                console.error(`[wsemi ${tag}] listener of '${String(name)}' threw:`, err)
            }
        }
        catch (err2) {
            try {
                console.error(`[wsemi ${tag}] listener of '${String(name)}' threw, and reporting it also threw:`, err2, err)
            }
            catch (err3) {
            }
        }

        return false
    }
}


export default evEmit