createServiceManager.mjs

import probeHttp from './probeHttp.mjs'
import killOwnTree from './killOwnTree.mjs'
import killPortListeners from './killPortListeners.mjs'


/**
 * 建立e2e服務生命週期管理器(沿用政策reuse):port已有服務即沿用(不spawn也不負責關),沒有才spawn並等就緒;只殺自己spawn的
 *
 * startServersOnce:依services順序逐一處理;每個服務各自一個一次性狀態(進行中之呼叫共用同一個promise)。
 * 失敗語意failureMode:'once'(預設,首次呼叫拋錯,之後之呼叫不再重試也不再拋錯;沿用w-web-sso現行語意)或'sticky'(之後之呼叫持續拋出同一錯誤,直到cleanup)。
 * restart:有自建行程者先同步殺整棵樹並等port釋放(有上限);等不到釋放、或無自建行程而port被佔用者,killForeignOnRestart為true才以killPortListeners殺監聽者
 * (只可用於該port專屬本專案且已於專案映射表明文登錄之情形),否則拋錯(自建殺後仍被佔者只記錄並繼續,沿用前版語意);之後以args與env(與process.env淺合併,只作用於本次)spawn並等就緒。restart不改變一次性狀態。
 * 服務可給beforeSpawn掛鉤(async({phase,args,env,hookArg})):每次spawn之前呼叫並等待,phase為'start'(startServersOnce確定要spawn時)或'restart',
 * hookArg為restart之opt.hookArg(例如{reseed:true});用於須在服務啟動前完成之前置(例如資料庫須在後端開啟前重建)。
 * cleanup:同步(exit與訊號處理器內非同步不會被等待);依services反序殺自建行程、重置所有一次性狀態(使之後之startServersOnce重新偵測)、呼叫onCleanup。
 * 另有政策own(只用自建實例、port被佔即報錯、跨行程互斥)未於本版提供,參考w-web-task之test/tools/harnessLifecycle.mjs
 *
 * @param {Object} opt 輸入設定物件
 * @param {Array} opt.services 輸入服務陣列,每個為{name,port,spawn({args,env})=>ChildProcess,readyTimeoutMs,url,startNote,beforeSpawn},readyTimeoutMs預設30000,url預設http://<host>:<port>/,beforeSpawn可省略
 * @param {String} [opt.host='127.0.0.1'] 輸入主機字串,預設'127.0.0.1'
 * @param {Function} [opt.probe] 輸入就緒探測函數async(svc)=>Boolean,預設probeHttp(svc.url,{timeoutMs:1500})(任何HTTP回應即就緒)
 * @param {Number} [opt.pollMs=500] 輸入就緒輪詢間隔毫秒數,預設500
 * @param {String} [opt.failureMode='once'] 輸入啟動失敗後之語意字串,'once'或'sticky',預設'once'
 * @param {Boolean} [opt.killForeignOnRestart=false] 輸入restart時可否殺非自建之port監聽者布林值,預設false
 * @param {Number} [opt.portReleaseTimeoutMs=5000] 輸入等待port釋放之上限毫秒數,預設5000
 * @param {Number} [opt.portReleasePollMs=200] 輸入等待port釋放之輪詢間隔毫秒數,預設200
 * @param {Function} [opt.killTree=killOwnTree] 輸入同步殺自建行程樹函數(child),預設killOwnTree
 * @param {Function} [opt.killListeners=killPortListeners] 輸入同步殺port監聽者函數(port)=>{pids,killed},預設killPortListeners
 * @param {Function} [opt.sleep] 輸入等待函數async(ms),預設setTimeout
 * @param {Function} [opt.onCleanup=null] 輸入cleanup末尾之同步回呼(例如刪臨時設定檔),預設null
 * @param {Function} [opt.log=console.log] 輸入輸出函數,預設console.log
 * @returns {Object} 回傳物件{startServersOnce({only}),restart(name,{args,env,hookArg}),cleanup(),state(name)}
 * @example
 *
 * import { spawn } from 'child_process'
 * import createServiceManager from 'w-package-tools-e2e/src/createServiceManager.mjs'
 *
 * let sm = createServiceManager({
 *     services: [
 *         { name: 'backend', port: 11007, readyTimeoutMs: 30000, spawn: ({ args, env }) => spawn('node', ['srv.mjs', ...args], { stdio: 'ignore', env }) },
 *         { name: 'frontend', port: 8080, readyTimeoutMs: 90000, spawn: () => spawn('npm', ['run', 'serve'], { stdio: 'ignore', shell: true }) },
 *     ],
 *     killForeignOnRestart: true,
 * })
 * await sm.startServersOnce()
 * await sm.restart('backend', { args: ['./test/_tmp/settings-e2e.json'] })
 * sm.cleanup()
 *
 */
function createServiceManager(opt = {}) {
    let {
        services,
        host = '127.0.0.1',
        probe = null,
        pollMs = 500,
        failureMode = 'once',
        killForeignOnRestart = false,
        portReleaseTimeoutMs = 5000,
        portReleasePollMs = 200,
        killTree = killOwnTree,
        killListeners = killPortListeners,
        sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
        onCleanup = null,
        log = console.log,
    } = opt
    if (!Array.isArray(services) || services.length === 0) {
        throw new Error('createServiceManager: services 為必填陣列')
    }
    if (!['once', 'sticky'].includes(failureMode)) {
        throw new Error(`createServiceManager: failureMode 須為 once 或 sticky,實得「${failureMode}」`)
    }
    let svcs = services.map((s) => ({ readyTimeoutMs: 30000, url: `http://${host}:${s.port}/`, ...s }))
    let names = new Set()
    for (let s of svcs) {
        if (!s.name || !s.port || typeof s.spawn !== 'function') {
            throw new Error('createServiceManager: 每個服務須有 name、port、spawn')
        }
        if (s.beforeSpawn !== undefined && s.beforeSpawn !== null && typeof s.beforeSpawn !== 'function') {
            throw new Error(`createServiceManager: 服務 ${s.name} 之 beforeSpawn 須為函數`)
        }
        if (names.has(s.name)) {
            throw new Error(`createServiceManager: 服務名稱重複 ${s.name}`)
        }
        names.add(s.name)
    }
    let probeFn = probe || ((svc) => probeHttp(svc.url, { timeoutMs: 1500 }))
    let states = {}
    for (let s of svcs) {
        states[s.name] = { proc: null, pm: null }
    }
    let byName = (name) => {
        let s = svcs.find((x) => x.name === name)
        if (!s) {
            throw new Error(`createServiceManager: 無此服務 ${name}`)
        }
        return s
    }

    async function waitReady(svc) {
        let start = Date.now()
        while (Date.now() - start < svc.readyTimeoutMs) {
            if (await probeFn(svc)) {
                return
            }
            await sleep(pollMs)
        }
        throw new Error(`server not ready on port ${svc.port} after ${svc.readyTimeoutMs / 1000}s`)
    }

    async function waitReleased(svc) {
        let start = Date.now()
        while (Date.now() - start < portReleaseTimeoutMs) {
            if (!(await probeFn(svc))) {
                return true
            }
            await sleep(portReleasePollMs)
        }
        log(`[e2e-setup] port ${svc.port} 於 ${portReleaseTimeoutMs}ms 內仍有回應, 照常繼續`)
        return false
    }

    async function ensure(svc) {
        if (await probeFn(svc)) {
            log(`[e2e-setup] ${svc.name} already running on ${svc.port}, reusing`)
            return
        }
        log(`[e2e-setup] starting ${svc.name} (port ${svc.port})${svc.startNote ? `, ${svc.startNote}` : ''}...`)
        if (svc.beforeSpawn) {
            await svc.beforeSpawn({ phase: 'start', args: [], env: process.env, hookArg: null })
        }
        states[svc.name].proc = svc.spawn({ args: [], env: process.env })
        await waitReady(svc)
        log(`[e2e-setup] ${svc.name} ready`)
    }

    async function startServersOnce(o = {}) {
        let { only = null } = o
        let list = only ? svcs.filter((s) => only.includes(s.name)) : svcs
        for (let svc of list) {
            let st = states[svc.name]
            if (st.pm) {
                if (failureMode === 'sticky') {
                    await st.pm
                }
                else {
                    await st.pm.catch(() => {})
                }
                continue
            }
            st.pm = ensure(svc)
            await st.pm
        }
    }

    function killForeign(svc) {
        let r = killListeners(svc.port)
        if (r && r.pids === null) {
            log(`[e2e-setup] 查詢 port ${svc.port} 監聽者之工具不可用, 未殺`)
        }
    }

    async function restart(name, o = {}) {
        let { args = [], env = null, hookArg = null } = o
        let svc = byName(name)
        let st = states[name]
        if (st.proc) {
            killTree(st.proc)
            let released = await waitReleased(svc)
            //自建已殺而 port 仍有回應: 另有同專案實例佔用 (例如手動啟動者); 允許時殺其監聽者, 否則沿前版只記錄並繼續
            if (!released && killForeignOnRestart) {
                killForeign(svc)
                await waitReleased(svc)
            }
        }
        else if (await probeFn(svc)) {
            if (!killForeignOnRestart) {
                throw new Error(`restart ${name}: port ${svc.port} 被非本行程建立之服務佔用, 且未允許殺外部行程 (killForeignOnRestart=false)`)
            }
            killForeign(svc)
            await waitReleased(svc)
        }
        st.proc = null
        let spawnEnv = env ? { ...process.env, ...env } : process.env
        if (svc.beforeSpawn) {
            await svc.beforeSpawn({ phase: 'restart', args, env: spawnEnv, hookArg })
        }
        st.proc = svc.spawn({ args, env: spawnEnv })
        await waitReady(svc)
    }

    function cleanup() {
        for (let svc of [...svcs].reverse()) {
            let st = states[svc.name]
            if (st.proc) {
                try {
                    killTree(st.proc)
                }
                catch (err) {}
                st.proc = null
            }
            st.pm = null
        }
        if (typeof onCleanup === 'function') {
            onCleanup()
        }
    }

    function state(name) {
        let st = states[byName(name).name]
        return { owned: !!st.proc, pid: st.proc ? st.proc.pid : null, attempted: !!st.pm }
    }

    return { startServersOnce, restart, cleanup, state }
}


export default createServiceManager