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'
/**
* 對stdio協定型CLI依序送出一行一則JSON之請求並收集回應(疊於execCliSession之上),適用codex app-server、MCP之stdio伺服器等以換行分隔JSON之協定(LSP為Content-Length分幀,不適用)
* 不內建任何協定知識,initialize/initialized等握手由呼叫端以請求陣列表達
* 本函數不throw,一律以結果物件之ok與error、errorType欄位回報成敗
*
* 規則:
* - 請求物件含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
* @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否全部成功布林值)、results(以method為鍵之result物件,失敗者為null)、responses(依序之回應陣列,每項為{ id, method, result, error })、error(錯誤訊息字串,成功時為空字串)、errorType(錯誤類型字串: params(參數錯誤)、notfound(命令不存在)、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,
}
}
//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之物件`)
}
}
//opt
if (!isobj(opt)) {
opt = {}
}
//timeoutMs
let timeoutMs = get(opt, 'timeoutMs')
if (!ispint(timeoutMs)) {
timeoutMs = 120000
}
//jsonrpc
let jsonrpc = get(opt, 'jsonrpc')
if (jsonrpc === true) {
jsonrpc = '2.0'
}
if (!isestr(jsonrpc)) {
jsonrpc = null
}
//continueOnError
let continueOnError = get(opt, 'continueOnError')
if (!isbol(continueOnError)) {
continueOnError = false
}
//callbacks
let onNotify = get(opt, 'onNotify')
let onServerRequest = get(opt, 'onServerRequest')
//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, {
cwd: get(opt, 'cwd'),
env: get(opt, 'env'),
timeoutMs,
exitGraceMs: get(opt, 'exitGraceMs'),
onStderr: get(opt, 'onStderr'),
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 (isfun(onServerRequest)) {
onServerRequest(msg, s)
}
return
}
//伺服器通知: 無id
if (isfun(onNotify)) {
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 (isestr(r.error) && r.error.includes('ENOENT')) {
errorType = 'notfound'
}
let error = isestr(r.error) ? r.error : `子進程於回應前結束(code ${r.code})`
return rFail(errorType, `${method}: ${error}`, { results, responses, exitCode: r.code })
}
//依序送出
let failed = null
for (let q of requests) {
//通知: 不配id, 送出即繼續
if (!isestr(q.method)) {
let ok = await s.writeLine(JSON.stringify(buildMsg(undefined, q.notify, q.params)))
if (!ok) {
let r = exitResult || (await s.stop())
failed = exitToFail(r, q.notify)
break
}
continue
}
//請求: 配id並等待回應
let id = nextId
nextId += 1
let pm = genPm()
pending.set(id, { method: q.method, pm })
let ok = await s.writeLine(JSON.stringify(buildMsg(id, q.method, q.params)))
if (!ok) {
pending.delete(id)
let r = exitResult || (await s.stop())
failed = exitToFail(r, q.method)
break
}
let got = await pm
//子進程於回應前結束
if (got.exit) {
failed = exitToFail(got.exit, q.method)
break
}
//回應
let msg = got.msg
if ('error' in msg && msg.error !== null && msg.error !== undefined) {
results[q.method] = null
responses.push({ id, method: q.method, result: null, error: msg.error })
if (!continueOnError) {
let em = get(msg, 'error.message')
failed = rFail('rpc', `${q.method}: ${isestr(em) ? em : JSON.stringify(msg.error)}`, { results, responses })
break
}
continue
}
results[q.method] = msg.result
responses.push({ id, method: q.method, result: msg.result, error: null })
}
//收尾: 全部完成(或已失敗)後才stop, 此時之退出不視為失敗
let r = await s.stop()
//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