import get from 'lodash-es/get.js'
import genPm from './genPm.mjs'
import isfun from './isfun.mjs'
import isarr from './isarr.mjs'
import isobj from './isobj.mjs'
import isestr from './isestr.mjs'
import isbol from './isbol.mjs'
import ispint from './ispint.mjs'
import execCliSession from './execCliSession.mjs'
import execSpawnError from './_execSpawnError.mjs'
import execThrownMsg from './_execThrownMsg.mjs'
/**
* 對stdio協定型CLI依序送出一行一則JSON之請求並收集回應(疊於execCliSession之上),適用codex app-server、MCP之stdio伺服器等以換行分隔JSON之協定(LSP為Content-Length分幀,不適用)
* 不內建任何協定知識,initialize/initialized等握手由呼叫端以請求陣列表達
* 本函數不throw亦不reject,一律以結果物件之ok與error、errorType欄位回報成敗,error不含args與env之值;command、args、requests、opt或其選項值不可讀(getter拋錯、已撤銷之Proxy等)時不啟動子進程,errorType為params;請求之params無法序列化為JSON(如含BigInt、循環參照)時errorType亦為params,error為'<method>: 無法序列化為JSON: <訊息>',已啟動之子進程照常經stop()結束
*
* 規則:
* - 請求物件含method者為請求,自動配id(自0遞增)並等待同id之回應;含notify者為通知,不配id,送出即繼續
* - 回應之判定為「有id且含result或error」;帶id但含method者為伺服器發給客戶端之請求,交由onServerRequest處理(未提供則忽略);無id者為伺服器通知,交由onNotify(未提供則忽略)
* - 依序送出、逐一等待;任一回應含error即中止(results[該方法]=null,errorType='rpc'),除非continueOnError=true
* - 全部完成後才stop();此時之退出不視為失敗
*
* Unit Test: {@link https://github.com/yuda-lyu/wsemi/blob/master/test/execCliJsonRpc.test.mjs Github}
* @memberOf wsemi
* @param {String} command 輸入執行檔名稱字串
* @param {Array} [args=[]] 輸入參數字串陣列,預設[]
* @param {Array} [requests=[]] 輸入請求物件陣列,每項為{ method, params }(請求,等回應)或{ notify, params }(通知,不等回應),params可省略,陣列於呼叫時讀取為快照(其後對該陣列之增刪不影響本次送出),預設[]
* @param {Object} [opt={}] 輸入設定物件
* @param {Number} [opt.timeoutMs=120000] 輸入整體逾時毫秒正整數,涵蓋全部請求與收尾,逾時即樹殺,預設120000
* @param {String} [opt.cwd=process.cwd()] 輸入子進程工作目錄字串,預設process.cwd()
* @param {Object} [opt.env=undefined] 輸入額外注入之環境變數物件,語意同execCli,預設undefined
* @param {Number} [opt.exitGraceMs=2000] 輸入收尾stop()之寬限毫秒,語意同execCliSession,預設2000
* @param {String|Boolean} [opt.jsonrpc=undefined] 輸入每則訊息附加之jsonrpc欄位值,true代表'2.0'(MCP等JSON-RPC 2.0協定需要),字串則原樣附加,預設undefined代表不附加(codex app-server不需要)
* @param {Boolean} [opt.continueOnError=false] 輸入回應含error時是否繼續送出後續請求布林值,預設false
* @param {Function} [opt.onNotify=undefined] 輸入伺服器通知(無id訊息)回調函數,格式為(msg)=>{},預設undefined
* @param {Function} [opt.onServerRequest=undefined] 輸入伺服器發給客戶端之請求(帶id且含method)回調函數,格式為(msg, session)=>{},可用session.writeLine回覆,預設undefined
* @param {Function} [opt.onStderr=undefined] 輸入stderr片段回調函數,格式為(chunk)=>{},預設undefined。onNotify、onServerRequest、onStderr內拋出之錯誤不會被攔截,將成為未捕捉例外,請於回調內自行處理(該錯誤於回調返回後立即(微任務)原樣重拋,不影響其餘訊息之處理與本函數之結果)
* @param {Function} [opt.onTrace=null] 輸入觀察回調函數,原樣轉交execCliSession,事件即其工作階段之事件(type為spawn、data、write、stop、grace、timeout、kill、settle,欄位與時機見execCliSession之opt.onTrace,其中write為每則請求或通知寫入stdin一次,timeout之ms為本函數之timeoutMs,stop與grace之ms為exitGraceMs),本函數自身無計時器亦不另通知;回調不帶接收者呼叫,其拋錯與回傳值皆忽略,不影響本函數;opt.onTrace屬性本身不可讀(getter拋錯)與onStderr相同,errorType為params,預設null
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否全部成功布林值)、results(以method為鍵之result物件,失敗者為null)、responses(依序之回應陣列,每項為{ id, method, result, error })、error(錯誤訊息字串,成功時為空字串)、errorType(錯誤類型字串: params(參數錯誤,含輸入不可讀與params無法序列化)、notfound(子進程未能啟動,如命令不存在、npm shim之入口檔不存在、非有效執行檔、權限不足、cwd不存在、交execCliSession之cwd、env、exitGraceMs之值不可讀)、timeout(逾時)、exit(已啟動之子進程於回應前結束)、rpc(回應含error),成功時為空字串)、durationMs(耗時毫秒)、exitCode(子進程離開碼)
* @example
* //need test in nodejs
*
* async function test() {
*
* let nodeBin = process.execPath
*
* //模擬一行一則JSON之伺服器: 收到請求回{ id, result }, 方法為fail者回error, 收到EOF即結束
* let sc = `
* let rl = require('readline').createInterface({ input: process.stdin })
* rl.on('line', (l) => {
* let m = JSON.parse(l)
* if (m.id === undefined) return
* if (m.method === 'fail') {
* process.stdout.write(JSON.stringify({ id: m.id, error: { code: -1, message: 'bad' } }) + '\\n')
* }
* else {
* process.stdout.write(JSON.stringify({ id: m.id, result: { method: m.method, params: m.params } }) + '\\n')
* }
* })
* rl.on('close', () => process.exit(0))
* `
*
* let r = await execCliJsonRpc(nodeBin, ['-e', sc], [
* { method: 'initialize', params: { clientInfo: { name: 'demo' } } },
* { notify: 'initialized' },
* { method: 'account/read' },
* ])
* console.log(r.ok, JSON.stringify(r.errorType), r.results['account/read'], r.exitCode)
* // => true "" { method: 'account/read' } 0
*
* let r2 = await execCliJsonRpc(nodeBin, ['-e', sc], [
* { method: 'fail' },
* { method: 'never' },
* ])
* console.log(r2.ok, r2.errorType, r2.results, r2.error)
* // => false rpc { fail: null } fail: bad
*
* }
* test()
* .catch((err) => {
* console.log(err)
* })
*
*/
async function execCliJsonRpc(command, args = [], requests = [], opt = {}) {
//startTime
let startTime = Date.now()
//rFail, 統一失敗結構
let rFail = (errorType, error, ext = {}) => {
return {
ok: false,
results: {},
responses: [],
error,
errorType,
durationMs: Date.now() - startTime,
exitCode: null,
...ext,
}
}
//輸入之讀取與判型一律於此try內: 拋錯者(command、args、requests、opt或其選項值之getter拋錯、已撤銷之Proxy等不可讀之輸入)以errorType為params之結果物件回報, 不reject, 不啟動子進程
//交給execCliSession之cwd、env、exitGraceMs亦於此讀取而原樣轉交, 其判型由execCliSession負責(不可讀之值由其以未能啟動回報, 即notfound)
let timeoutMs = 120000
let jsonrpc = null
let continueOnError = false
let onNotify
let onServerRequest
let optSession = {}
let reqs = [] //requests之快照(入口逐項讀取之元素), 其後依序送出時迭代此陣列, 不再迭代呼叫端之陣列
try {
let v
//command
if (!isestr(command)) {
return rFail('params', 'command 須為非空字串')
}
//args
if (!isarr(args)) {
args = []
}
//requests
if (!isarr(requests)) {
return rFail('params', 'requests 須為陣列')
}
for (let i = 0; i < requests.length; i++) {
let q = requests[i]
if (!isobj(q) || (!isestr(q.method) && !isestr(q.notify))) {
return rFail('params', `requests[${i}] 須為含method或notify之物件`)
}
reqs.push(q)
}
//opt
if (!isobj(opt)) {
opt = {}
}
//timeoutMs
v = get(opt, 'timeoutMs')
timeoutMs = ispint(v) ? v : 120000
//jsonrpc
v = get(opt, 'jsonrpc')
if (v === true) {
v = '2.0'
}
jsonrpc = isestr(v) ? v : null
//continueOnError
v = get(opt, 'continueOnError')
continueOnError = isbol(v) ? v : false
//callbacks, 於此判型一次(非函數者為undefined), onLine內不再觸及呼叫端之值
v = get(opt, 'onNotify')
onNotify = isfun(v) ? v : undefined
v = get(opt, 'onServerRequest')
onServerRequest = isfun(v) ? v : undefined
//optSession, onStderr同為回調而於此判型(不可讀為params); cwd、env、exitGraceMs原樣轉交execCliSession判型; onTrace(觀察回調)原樣轉交execCliSession, 事件即其工作階段之事件(本函數自身無計時器亦不另通知)
v = get(opt, 'onStderr')
optSession = {
cwd: get(opt, 'cwd'),
env: get(opt, 'env'),
exitGraceMs: get(opt, 'exitGraceMs'),
onStderr: isfun(v) ? v : undefined,
onTrace: get(opt, 'onTrace'),
}
}
catch (err) {
let e = execSpawnError(err)
return rFail('params', `${e.code}: ${execThrownMsg(e, err)}`)
}
//state
let pending = new Map() //id → { method, pm }
let exitResult = null
let nextId = 0
let results = {}
let responses = []
//buildMsg
let buildMsg = (id, method, params) => {
let m = {}
if (jsonrpc !== null) {
m.jsonrpc = jsonrpc
}
if (id !== undefined) {
m.id = id
}
m.method = method
if (params !== undefined) {
m.params = params
}
return m
}
//s
let s = execCliSession(command, args, {
...optSession,
timeoutMs,
onLine: (line) => {
line = line.trim()
if (line === '') {
return
}
//parse, 非JSON行(如日誌)忽略
let msg
try {
msg = JSON.parse(line)
}
catch {
return
}
if (!isobj(msg)) {
return
}
//hasId
let hasId = (msg.id !== undefined && msg.id !== null)
//回應: 有id且含result或error
if (hasId && (('result' in msg) || ('error' in msg))) {
let p = pending.get(msg.id)
if (p) {
pending.delete(msg.id)
p.pm.resolve({ msg })
}
return
}
//伺服器→客戶端之請求: 帶id且含method
if (hasId && isestr(msg.method)) {
if (onServerRequest !== undefined) { //已於入口判型(函數或undefined), onLine內不再判型呼叫端之值(值於入口後撤銷時判型會拋出)
onServerRequest(msg, s)
}
return
}
//伺服器通知: 無id
if (onNotify !== undefined) { //同onServerRequest
onNotify(msg)
}
},
onExit: (r) => {
exitResult = r
//子進程結束, 喚醒所有等待中之請求
for (let [id, p] of pending) { // eslint-disable-line no-unused-vars
p.pm.resolve({ exit: r })
}
pending.clear()
},
})
//exitToFail, 依退出結果判別errorType
let exitToFail = (r, method) => {
let errorType = 'exit'
if (r.timeout) {
errorType = 'timeout'
}
else if (r.pid === null || r.pid === undefined) {
errorType = 'notfound' //子進程從未啟動(execCliSession之pid為null): 不論錯誤碼(ENOENT、UNKNOWN、EPERM、參數檢查錯誤、cwd不存在等)皆屬無法啟動, 不以錯誤字樣判定
}
let error = isestr(r.error) ? r.error : `子進程於回應前結束(code ${r.code})`
return rFail(errorType, `${method}: ${error}`, { results, responses, exitCode: r.code })
}
//依序送出, 迭代入口之快照reqs(迭代呼叫端之陣列位於try外, 陷阱非決定性之Proxy陣列會於此拋出而reject且子進程殘留至逾時)
let failed = null
for (let q of reqs) {
//line, 讀取請求物件並序列化須在try內: params無法序列化(BigInt、循環參照、toJSON拋錯、已撤銷之Proxy等)時以params回報並break, 交下方收尾之stop()結束子進程(不得直接拋出而使子進程殘留至逾時); 其後一律用讀取當下之快照
let method
let notify
let isNotify
let id
let line
try {
method = q.method
notify = q.notify
isNotify = !isestr(method)
id = isNotify ? undefined : nextId
line = JSON.stringify(buildMsg(id, isNotify ? notify : method, q.params))
}
catch (err) {
let e = execSpawnError(err)
let name = typeof method === 'string' && method !== '' ? method : (typeof notify === 'string' ? notify : 'request') //catch內不再呼叫可拋錯之判型
failed = rFail('params', `${name}: 無法序列化為JSON: ${execThrownMsg(e, err)}`, { results, responses })
break
}
//通知: 不配id, 送出即繼續
if (isNotify) {
let ok = await s.writeLine(line)
if (!ok) {
let r = exitResult || (await s.stop())
failed = exitToFail(r, notify)
break
}
continue
}
//請求: 配id並等待回應
nextId += 1
let pm = genPm()
pending.set(id, { method, pm })
let ok = await s.writeLine(line)
if (!ok) {
pending.delete(id)
let r = exitResult || (await s.stop())
failed = exitToFail(r, method)
break
}
let got = await pm
//子進程於回應前結束
if (got.exit) {
failed = exitToFail(got.exit, method)
break
}
//回應
let msg = got.msg
if ('error' in msg && msg.error !== null && msg.error !== undefined) {
results[method] = null
responses.push({ id, method, result: null, error: msg.error })
if (!continueOnError) {
let em = get(msg, 'error.message')
failed = rFail('rpc', `${method}: ${isestr(em) ? em : JSON.stringify(msg.error)}`, { results, responses })
break
}
continue
}
results[method] = msg.result
responses.push({ id, method, result: msg.result, error: null })
}
//收尾: 全部完成(或已失敗)後才stop, 此時之退出不視為失敗
let r = await s.stop()
//未啟動: 尚未失敗而子進程從未啟動(requests為空陣列時迴圈不經exitToFail), 同exitToFail一律notfound, 不得偽回成功
if (!failed && (r.pid === null || r.pid === undefined)) {
let error = isestr(r.error) ? r.error : '子進程未能啟動'
failed = rFail('notfound', error, { results, responses, exitCode: r.code })
}
//failed
if (failed) {
failed.durationMs = Date.now() - startTime
if (failed.exitCode === null) {
failed.exitCode = r.code
}
return failed
}
//continueOnError下若有rpc錯誤仍以ok:false回報, 但results與responses完整
let rpcErrs = responses.filter((x) => x.error !== null)
if (rpcErrs.length > 0) {
let x = rpcErrs[0]
let em = get(x, 'error.message')
return {
ok: false,
results,
responses,
error: `${x.method}: ${isestr(em) ? em : JSON.stringify(x.error)}`,
errorType: 'rpc',
durationMs: Date.now() - startTime,
exitCode: r.code,
}
}
return {
ok: true,
results,
responses,
error: '',
errorType: '',
durationMs: Date.now() - startTime,
exitCode: r.code,
}
}
export default execCliJsonRpc