WConverhpClient.mjs

import axios from 'axios'
import get from 'lodash-es/get.js'
import size from 'lodash-es/size.js'
import isWindow from 'wsemi/src/isWindow.mjs'
import evem from 'wsemi/src/evem.mjs'
import evEmitBase from 'wsemi/src/evEmit.mjs'
import genPm from 'wsemi/src/genPm.mjs'
import getErrorMessage from 'wsemi/src/getErrorMessage.mjs'
import haskey from 'wsemi/src/haskey.mjs'
import isfun from 'wsemi/src/isfun.mjs'
import ispint from 'wsemi/src/ispint.mjs'
import isp0int from 'wsemi/src/isp0int.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isobj from 'wsemi/src/isobj.mjs'
import iseobj from 'wsemi/src/iseobj.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import ispm from 'wsemi/src/ispm.mjs'
import cint from 'wsemi/src/cint.mjs'
import dig from 'wsemi/src/dig.mjs'
import strright from 'wsemi/src/strright.mjs'
import b642str from 'wsemi/src/b642str.mjs'
import blob2u8arr from 'wsemi/src/blob2u8arr.mjs'
import obj2u8arr from 'wsemi/src/obj2u8arr.mjs'
import u8arr2obj from 'wsemi/src/u8arr2obj.mjs'
import pmConvertResolve from 'wsemi/src/pmConvertResolve.mjs'
import delay from 'wsemi/src/delay.mjs'
import getFileXxHash from 'wsemi/src/getFileXxHash.mjs'
import sanitizeFilename from './sanitizeFilename.mjs'
import isPathInside from './isPathInside.mjs'
import retryDelay from './retryDelay.mjs'


/**
 * 將 upload 之輸入正規化為兩種表示之一: Blob(含 File)或位元組視圖(Uint8Array, Buffer 原樣保留)
 *
 * why: 原本 upload 不正規化, 而大小、切片、雜湊三者對同一輸入各自解讀 —— 大小以 bb.size、bb.length 依序猜, 切片以 bb.slice, 雜湊以 new Blob([inp]);
 * 實測(第十輪 D2): ArrayBuffer 兩個屬性皆無而大小取 1, 雜湊卻以整個 ArrayBuffer 計, 應用端以 success 收到 **1 byte**;
 * DataView 無 slice 而拋 TypeError; Uint16Array 之 length 為元素數而切出之位元組為 2 倍 → Payload Too Large; 非 ASCII 字串以字元數切、以 UTF-8 送 → 同;
 * null 等不支援之輸入則以看不出原因之 TypeError 失敗且 0 則事件。
 *
 * 位元組語意與 fetch / axios 之 BodyInit 一致: ArrayBuffer 與所有 ArrayBufferView 取其位元組, 字串取其 UTF-8 位元組。
 * 判定 Blob 以 instanceof 而非 wsemi 之 isblob: File 之 Object.prototype.toString 為 [object File], isblob 對其回 false(nodejs 實測)。
 * 非 Buffer 之視圖以 new Uint8Array(buffer, byteOffset, byteLength) 表達, 其後切片須用會複製之 slice(使 axios 送出之 data.buffer 恰為該片;
 * axios 對非 Buffer 之視圖送 data.buffer, 以 subarray 切出之視圖會連同底層其餘位元組一併送出)。
 *
 * @param {*} v 輸入 upload 之輸入
 * @returns {Blob|Uint8Array|null} 回傳正規化後之輸入; 不支援者回 null
 */
function normalizeUploadInput(v) {
    try {
        if (typeof Blob !== 'undefined' && v instanceof Blob) {
            return v
        }
        if (typeof Buffer !== 'undefined' && Buffer.isBuffer(v)) {
            return v
        }
        if (v instanceof ArrayBuffer) {
            return new Uint8Array(v)
        }
        if (ArrayBuffer.isView(v)) {
            return new Uint8Array(v.buffer, v.byteOffset, v.byteLength)
        }
        if (typeof v === 'string') {
            return new TextEncoder().encode(v)
        }
    }
    catch (err) {}
    return null
}


//decodeFilenameFromHeader, /dw 之協定解碼: Content-Disposition 之 filename="<base64>" → 還原 → 淨化
//why 與落檔分開(B 卷 §③-3.5): 解碼是協定, 落檔是 I/O; 混在一起使 'unknow' 拼字錯藏了多輪(N12)。取不到者為 unknown(b642str('unknown') 為空字串, 再經 sanitizeFilename 回 unknown, 行為自洽)
//淨化(sanitizeFilename): 檔名來自伺服器不可信, 只取最末路徑段並去除非法字元(含可逸出之 Windows 磁碟機相對路徑 C:x)與保留裝置名 ——
//本值由套件自己拿去 path.resolve 落地, 故就地淨化(帳本 R19: 誰要拿它做危險操作, 誰處理; 只轉交者不得改寫)
function decodeFilenameFromHeader(contentDisposition) {
    let fn = 'unknown'
    try {
        let matches = /filename="(.+?)"/.exec(contentDisposition)
        fn = matches ? matches[1] : 'unknown'
    }
    catch (err) {}
    return sanitizeFilename(b642str(fn)) //headers內對中文支援度不佳須用base64傳, 此處解析提取後須反轉
}

//drainResponse, 離開 downloadStream 前排空對端串流: nodejs 之 responseType:'stream' 時 res.data 為 IncomingMessage, 未消費之回應會使該 socket 於 keep-alive 下直到伺服器 keepAliveTimeout 才釋放,
//且位於重試鏈內時每次嘗試各留一條(第十一輪 N8、B9、A2); 瀏覽器之 Blob 無 resume 即略過; 已被 pipeline 銷毀者 resume 為 no-op
function drainResponse(res) {
    try {
        let d = get(res, 'data')
        if (d && isfun(d.resume)) {
            d.resume()
        }
    }
    catch (err) {}
}

//saveStreamToFile, nodejs 之落檔: 以 pipeline 把回應串流寫入 fdDownload/filename, 回傳落點絕對路徑
//path, fs, stream 使用動態 import 且以變數字串給予, 否則用於前端時會被 webpack 偵測而報錯(**不可改為字面量**; 唯一保護為 e2e-download 之真瀏覽器)
//串流出錯由 pipeline 回報; 其餘同步失敗(路徑判定、mkdir、lstat)直接拋出, 由呼叫端排空對端串流後進重試
async function saveStreamToFile(streamRecv, fdDownload, filename) {
    let cImPath = 'path'
    let cImFs = 'fs'
    let cImStream = 'stream'
    let path = await import(cImPath)
    let fs = await import(cImFs)
    let stream = await import(cImStream)

    //fdDownload, 只有nodejs下載才使用fdDownload
    fs.mkdirSync(fdDownload, { recursive: true }) //須使用mkdirSync, 不要用fsIsFolder與fsCreateFolder避免編譯

    //fdReal, 以realpath正規化基準資料夾, 消除符號連結造成之路徑歧異(OWASP: 先正規化再做邊界判定)
    let fdReal = fs.realpathSync(fdDownload)

    //fp
    let fp = path.resolve(fdReal, filename)

    //check, 落點須仍在fdDownload之內(檔名已淨化, 此為第二道防線), 以path.relative判定, 不用startsWith(base+sep): 後者於base為磁碟根目錄時誤判
    if (!isPathInside(fdReal, fp, path)) {
        throw new Error('invalid filename from server')
    }

    //check, 目標已存在且為符號連結則拒絕: createWriteStream會穿過連結寫到其指向處, 預先植入之連結可使寫入逸出資料夾
    let st = null
    try {
        st = fs.lstatSync(fp)
    }
    catch (err) {}
    if (st !== null && st.isSymbolicLink()) {
        throw new Error('invalid filename from server')
    }

    //streamWriter
    let streamWriter = fs.createWriteStream(fp)

    //pipeline, 不用streamRecv.pipe(streamWriter): .pipe()不會因源串流出錯或中途斷線而關閉目的串流, 伺服器串流中途失敗時finish永不發生,
    //本promise永不settle(axios之timeout只涵蓋到回應標頭, 不涵蓋串流本體)、寫入fd開啟、殘留部分檔; pipeline對源與目的任一方出錯或提前關閉皆銷毀雙方並回報,
    //使失敗能reject而進入send之重試(傳輸不穩須重試, 前提是失敗要被偵測到)
    return new Promise((resolve, reject) => {
        stream.pipeline(streamRecv, streamWriter, (err) => {
            if (err) {

                //不完整檔須刪除, 否則殘留部分內容會被當成已下載之檔案; 刪除失敗不影響reject(重試會以寫入模式覆蓋)
                try {
                    fs.unlinkSync(fp)
                }
                catch (e) {}

                reject(err)
                return
            }
            resolve(fp)
        })
    })
}

//classifyFailure, send 之最終失敗值: 伺服器回傳值(業務錯誤)原樣交出; 本地失敗(axios、fs、解析)取可讀訊息
//why 判準採反向(非 Error 即伺服器回傳值): callApiCore 於伺服器回傳業務錯誤(如 'invalid func'、'permission denied', 或應用端 handler 之 reject 值)時是 reject 伺服器給的值本體而非 axios 錯誤物件;
//本地失敗一律為 Error 實例, 而經序列化自伺服器回來之值結構上不可能是 Error 實例, 故非 Error 者不論形狀(字串、物件、數字、陣列、空字串、null)皆原樣交出; 不可用形狀白名單, 外部應用端之拒絕值列不完
//訊息: 優先取 HTTP 之 statusText(如 Payload Too Large), 無者經 getErrorMessage(帳本 R10)。以 isestr 判定而非 ||: 空 reason-phrase 之 statusText 為空字串, || 會落到 response.data ——
//nodejs 串流下載時那是 IncomingMessage 物件(B 卷 B10); 且不再交出 stack: 原以 get(res,'stack') 兜底, 把整段本機 stack 當成呼叫端可見之錯誤值(A 卷 §①-1.3(e))
//Network Error 除可能是網路斷線之外, 可能被瀏覽器外掛封鎖阻擋, 亦可能因硬碟空間不足無法下載被瀏覽器拒絕
function classifyFailure(res) {
    if (!(res instanceof Error)) {
        return res
    }
    let statusText = get(res, 'response.statusText')
    let data = isestr(statusText) ? statusText : getErrorMessage(res)
    if (!isestr(data)) {
        data = 'Can not connect to server.'
    }
    if (data === 'Network Error') {
        data = `Network Error. Make sure your space of hard drive is large enough or blocking by browser plugins.`
    }
    return data
}


/**
 * 建立Hapi使用者(Node.js與Browser)端物件
 *
 * @class
 * @param {Object} opt 輸入設定參數物件
 * @param {String} [opt.url='http://localhost:8080'] 輸入Hapi伺服器網址,預設為'http://localhost:8080'
 * @param {String} [opt.apiName='api'] 輸入API名稱字串,預設'api'
 * @param {Function} [opt.getToken=()=>''] 輸入取得使用者token的回調函數,預設()=>''。可回傳字串或promise;回undefined或null視為未帶token(送出空token,不送字面之undefined)。**每一次請求嘗試各呼叫一次**(含重試,故一次execute為1+retryMain次),使重試帶當下之token;瀏覽器下載管理器路徑(downloadByManager=true)於取檔名之請求外,另於導覽至下載網址前再呼叫一次,故為(1+retryDownload)+1次(該次拋錯時download以錯誤拒絕並發一則error事件;此時伺服器已因取檔名之請求觸發過一次download事件而沒有下載發生);快取與否由應用端自行決定
 * @param {String} [opt.tokenType='Bearer'] 輸入token類型字串,預設'Bearer'
 * @param {Integer} [opt.sizeSlice=1024*1024] 輸入切片上傳檔案之切片檔案大小整數,單位為Byte,預設為1024*1024。須與伺服器之sizeSlice一致,伺服器以其sizeSlice為單一切片請求上限並據以判定切片是否完整,不一致時upload會於check-total-hash階段以sizeSlice mismatch訊息終止。須為安全整數,Infinity與超出安全範圍者視為無效取預設
 * @param {Integer} [opt.timeout=5*60*1000] 輸入最長等待時間整數,單位ms,預設為5*60*1000、為5分鐘。交予axios之timeout:於nodejs為socket閒置逾時(有資料往來即不計時),於瀏覽器為XHR之**整個請求**上限(含本體傳輸時間,故瀏覽器以blob模式下載或execute傳輸大檔時須依檔案大小與頻寬調大或給0);0為不逾時;須為安全整數,Infinity與超出安全範圍者視為無效取預設;另受計時器上限2147483647約束,超過者亦取預設。不可用Infinity或超大數字表示不逾時(axios會於請求送出前即拋錯,超大數字則因計時器溢位而立即逾時),不逾時請給0
 * @param {Integer} [opt.retryMain=3] 輸入主要控制器傳輸失敗重試次數整數,預設為3。凡失敗皆重試(含伺服器不穩、傳輸不穩、狀態不穩如permission denied與應用端reject),僅可證明不需重試之錯誤除外:伺服器標示retryable為false之參數檢核類錯誤,與HTTP 413。須為安全整數,Infinity與超出安全範圍者視為無效取預設;上限為20,超過者截為20(退避延遲為指數成長,次數無上限會使單次等待成長至數小時乃至溢位計時器)
 * @param {Integer} [opt.retryUpload=10] 輸入切片上傳檔案傳輸失敗重試次數整數,預設為10。重試範圍同retryMain,為每一次請求(含合併輪詢中之每一條查詢)之重試次數,非整個upload之總上限;合併完成後應用端upload事件拒絕時,依重試原則持續輪詢直到應用端接受為止。須為安全整數,Infinity與超出安全範圍者視為無效取預設;上限為20,超過者截為20
 * @param {Integer} [opt.retryDownload=2] 輸入下載檔案傳輸失敗重試次數整數,預設為2。重試範圍同retryMain;瀏覽器以下載管理器下載(downloadByManager=true)時僅涵蓋取檔名之請求,實際下載交由瀏覽器不在此重試範圍。須為安全整數,Infinity與超出安全範圍者視為無效取預設;上限為20,超過者截為20
 * @returns {Object} 回傳事件物件,可使用函數execute、upload、download,可監聽事件error。**監聽器須為同步函數,不可為async函數、亦不可回傳promise**:事件派發依EventEmitter規範丟棄監聽器之回傳值,其rejection無人觀察,於nodejs即unhandledRejection而使行程崩潰。upload之input可為Blob、File、ArrayBuffer、ArrayBufferView(以位元組計)或字串(以UTF-8位元組計),其餘以錯誤拒絕並發error事件。**error事件之契約:每一次請求嘗試失敗恰發一則**(execute、upload、download皆同;不分傳輸層失敗如連不上、逾時、HTTP 413或5xx,與伺服器回之業務錯誤如permission denied、應用端拒絕、合併失敗),故一次呼叫最多發1+重試次數則;入口即拒絕者(輸入不支援、無法序列化、未給fdDownload)一則。upload之合併輪詢依設計持續至應用端接受為止,長時間斷線時每一輪查詢皆各自重試並各發事件,事件數因此無上界(與「重試導致等待久」同為刻意),不需要者可不註冊error監聽器
 * @example
 *
 * import path from 'path'
 * import fs from 'fs'
 * import _ from 'lodash-es'
 * import w from 'wsemi'
 * import WConverhpClient from './src/WConverhpClient.mjs'
 *
 * let ms = []
 *
 * let opt = {
 *     url: 'http://localhost:8080',
 *     apiName: 'api',
 *     getToken: () => {
 *         return 'token-for-test'
 *     },
 * }
 *
 * //new
 * let wo = new WConverhpClient(opt)
 *
 * wo.on('error', (err) => {
 *     console.log(`error`, err)
 * })
 *
 * function downloadLargeFile() {
 *     let core = async() => {
 *
 *         await wo.download('id-for-file',
 *             function ({ prog, p, m }) {
 *                 // console.log('client web: download: prog', prog, p, m)
 *                 if (m === 'download') {
 *                     console.log('client web: download: prog', prog)
 *                 }
 *             },
 *             {
 *                 fdDownload: './', //於nodejs環境才能提供
 *             })
 *             .then(function(res) {
 *                 console.log('client web: download: then', res)
 *                 ms.push({ 'download output': res })
 *             })
 *             .catch(function (err) {
 *                 console.log('client web: download: catch', err)
 *             })
 *
 *         console.log('ms', ms)
 *
 *     }
 *     core()
 * }
 *
 * downloadLargeFile()
 *
 */
function WConverhpClient(opt) {

    //_url
    let _url = get(opt, 'url')
    if (!isestr(_url)) {
        _url = 'http://localhost:8080'
    }

    //apiName
    let apiName = get(opt, 'apiName')
    if (!isestr(apiName)) {
        apiName = 'api'
    }

    //url
    let url = ''
    if (strright(_url, 1) === '/') {
        url = _url + apiName
    }
    else {
        url = _url + '/' + apiName
    }

    //getToken
    let getToken = get(opt, 'getToken', null)
    if (!isfun(getToken)) {
        getToken = () => {
            return ''
        }
    }

    //tokenType
    let tokenType = get(opt, 'tokenType')
    if (!isestr(tokenType)) {
        tokenType = 'Bearer'
    }

    //maxTimer, node計時器以32位元帶號整數表達, 超過即溢位: axios之timeout給2**31實測不是等24.9日而是20ms即以「timeout of 2147483648ms exceeded」拒絕;
    //故凡進入計時器之毫秒值皆須以此為上限, 超過者視為無效而取預設(與Infinity同處置), 不採截斷: 要表達「不逾時」另有正式語意(timeout為0), 給超大數字屬誤用
    let maxTimer = 2147483647 //2**31-1

    //maxRetryTimes, 重試次數上限
    //why: 退避延遲為指數成長且以「第nToPeak次達到maxDelay」校準(見callApi), 次數若無上限則延遲隨之無界 ——
    //實測第20次單次等待16小時, 第27次起更超過32位元計時器上限而溢位成1ms, 退避反而塌陷為熱迴圈打伺服器.
    //本上限與callApi之延遲封頂成對: 上限擋住次數, 封頂擋住單次等待, 兩者缺一皆會使另一者失效
    let maxRetryTimes = 20

    //optSafe, 數值選項一律以wsemi之安全整數模式檢核: 其預設(寬鬆)對Infinity與超出安全整數者皆回true,
    //而Infinity會使切片數算成0(sizeSlice)、axios於送出前即拋錯(timeout)、重試次數終止條件永不成立(retry); 超出安全整數者則使伺服器端建構同步拋錯
    let optSafe = { useLimitSafe: true }

    //sizeSlice, 檢核與正規化須成對: ispint亦接受數字字串('1048576'), 而sizeSlice會與伺服器回傳者以!==比較(見sendDataSlice),
    //未正規化時字串與數值即判為mismatch而使上傳整個失敗, 縱使兩端組態實為相同值
    let sizeSlice = get(opt, 'sizeSlice')
    if (!ispint(sizeSlice, optSafe)) {
        sizeSlice = 1024 * 1024 //1m
    }
    sizeSlice = cint(sizeSlice)

    //timeout, 0為不逾時(axios語意); 交予計時器故另受maxTimer約束
    let timeout = get(opt, 'timeout')
    if (!isp0int(timeout, optSafe) || cint(timeout) > maxTimer) {
        timeout = 5 * 60 * 1000 //5min
    }
    timeout = cint(timeout)

    //retryMain
    let retryMain = get(opt, 'retryMain')
    if (!isp0int(retryMain, optSafe)) {
        retryMain = 3
    }
    retryMain = Math.min(cint(retryMain), maxRetryTimes) //超過上限者截為上限而非取預設: 取預設會減少重試次數, 而呼叫端給大值之意圖正是要多重試

    //retryUpload
    let retryUpload = get(opt, 'retryUpload')
    if (!isp0int(retryUpload, optSafe)) {
        retryUpload = 10
    }
    retryUpload = Math.min(cint(retryUpload), maxRetryTimes) //超過上限者截為上限而非取預設: 取預設會減少重試次數, 而呼叫端給大值之意圖正是要多重試

    //retryDownload
    let retryDownload = get(opt, 'retryDownload')
    if (!isp0int(retryDownload, optSafe)) {
        retryDownload = 2
    }
    retryDownload = Math.min(cint(retryDownload), maxRetryTimes) //超過上限者截為上限而非取預設: 取預設會減少重試次數, 而呼叫端給大值之意圖正是要多重試

    //env
    let env = isWindow() ? 'browser' : 'nodejs'
    // console.log('env', env)

    //ev, 原生eventemitter3(wsemi 1.8.91起evem不再包裝監聽器, 其語意完全遵循EventEmitter規範)
    let ev = evem()

    //evEmit, 於呼叫端之堆疊上派發, 交由wsemi之evEmit(其為「直接ev.emit並以try攔截」之單一擁有者)
    //why 不以setTimeout脫勾: setTimeout會開一個沒有呼叫者之堆疊, 監聽器之同步拋錯於該處即為uncaughtException,
    //於nodejs殺整個行程(瀏覽器則為console錯誤), 且呼叫端無論如何try都攔不到
    //why 不處理async監聽器: emit依EventEmitter規範丟棄監聽器之回傳值, 其rejection無人觀察; 見建構函數之JSDoc
    //client之事件僅error且不帶pm, 故無funSettle; 其error監聽器出錯者由wsemi之通報路徑走console.error留痕, 不再重發避免遞迴
    let evEmit = (name, ...args) => {
        return evEmitBase(ev, name, args, {
            tag: 'w-converhp-client',
        })
    }

    //symNoRetry, 標記「可證明不需重試」之錯誤: 伺服器於error封包標示retryable為false(參數檢核類, 結果僅由請求內容決定), 或HTTP 413(本體超過伺服器上限, 上限為伺服器建構參數, 重送同一本體必同一結果且每次重送整包)
    //以Symbol為鍵避免與伺服器回傳之任意值撞名; 僅於send內部流轉, 交予呼叫端前解包, 呼叫端仍收到原始error值; 其餘(permission denied、應用端reject、斷線等)皆屬狀態不穩, 依重試原則照常重試
    let symNoRetry = Symbol('noRetry')
    let isNoRetry = (msg) => {
        return (isobj(msg) && msg[symNoRetry] === true) || get(msg, 'response.status') === 413
    }
    let unwrapNoRetry = (msg) => {
        return (isobj(msg) && msg[symNoRetry] === true) ? msg.msg : msg
    }

    //fetchToken, 取當下 token 之唯一擁有者: 應用端之 getToken 可回值或 promise; undefined / null 視為未帶 token
    //why 單一擁有者: 第十輪 F11 於 callApiCore 內補了「每次嘗試重取 + undefined 不送字面」, 而瀏覽器下載管理器路徑(downloadBrowser)另有一份手寫之取 token 且未套 ——
    //getToken 回 undefined 時 URL 帶 token=undefined, 伺服器 isestr('undefined') 為真而以 Bearer undefined 交 verifyConn(同一規則兩站點只套一處)
    //拋錯或 reject 原樣向外拋, 由呼叫處決定其為該次嘗試之失敗(callApiCore, 進重試)或入口失敗(downloadBrowser, 一則事件 + 拒絕)
    let fetchToken = async() => {
        let token = getToken()
        if (ispm(token)) {
            token = await token
        }
        if (token === undefined || token === null) {
            token = ''
        }
        return token
    }

    //wrapNoRetry, 伺服器標示 retryable 為 false 之錯誤以 symNoRetry 包裝, 交由 callApi 中止重試並解包; 不發事件(事件由 attemptFailed 唯一擁有)
    //why 原本之 serverError 兼做「發事件」: 它只涵蓋業務錯誤(封包 / 標頭 / 合併查詢), 傳輸失敗 0 則 —— 同一個「這次嘗試失敗了」兩種處置(第十一輪 N3)
    let wrapNoRetry = (msg, retryable) => {
        if (retryable === false) {
            return { [symNoRetry]: true, msg }
        }
        return msg
    }

    //attemptFailed, 「一次嘗試失敗」之 error 事件唯一擁有者(帳本 R5 client 側): 不分傳輸(連不上、逾時、413、5xx)、業務(伺服器錯誤封包 / Return-Type:error / 合併查詢之 state:error)、
    //封包解析、getToken 拋錯, 每次嘗試恰一則。呼叫點只有兩個: callApi 之重試迴圈(每次 fun(s) 回 error)與 checkMerging(其 HTTP 請求成功而本體 state 為 error, 不經迴圈; 第十輪 F6 之站點)
    //why: 原本只有業務錯誤經 serverError 發事件, 傳輸失敗 0 則(實測第十一輪 N3: 連不上、413 皆 0 則), 應用端完全觀察不到斷線; send 之 catch 只在不可達之分支發事件且值為物件。
    //對標: socket.io-client 之 connect_error 不論傳輸方式、每次嘗試皆發出
    //事件值經 classifyFailure, 與最終拒絕值同一算法: 非 Error 者(伺服器回傳值)原樣; Error 者取可讀訊息(HTTP statusText 優先, 如 413 為 Payload Too Large; 帳本 R10)
    //合併輪詢無限(JSDoc 明載持續輪詢直到應用端接受), 故長時間斷線之 upload() 會持續產生事件(每輪最多 1+retryUpload 則, 速率受退避約束); 為「重試導致等待久」同族之刻意, 已於 JSDoc 明載(B 卷 B1)
    let attemptFailed = (msg) => {
        evEmit('error', classifyFailure(unwrapNoRetry(msg)))
    }

    //getUrlUse
    let getUrlUse = (type) => {

        //urlUse
        let urlUse = ''
        if (type === 'basic') {
            urlUse = `${url}/main`
        }
        else if (type === 'upload-controller') {
            urlUse = `${url}/ulctr`
        }
        else if (type === 'slice') {
            urlUse = `${url}/slc`
        }
        else if (type === 'download-get-filename') {
            urlUse = `${url}/dwgfn`
        }
        else if (type === 'download-get') {
            urlUse = `${url}/dwgf`
        }
        else if (type === 'download') {
            urlUse = `${url}/dw`
        }
        else {
            throw new Error(`invalid type[${type}]`)
        }

        return urlUse
    }

    //res2u8arr
    let res2u8arr = async(bb) => {
        //blob(in browser) or buffer(in nodejs) to u8a
        let u8a
        if (env === 'browser') {
            u8a = await blob2u8arr(bb)
        }
        else {
            u8a = new Uint8Array(bb)
        }
        return u8a
    }

    //u8arr2bb
    let u8arr2bb = (u8a) => {
        //u8a to blob(in browser) or buffer(in nodejs)
        let bb
        if (env === 'browser') {
            bb = new Blob([u8a.buffer])
        }
        else { //nodejs
            bb = Buffer.from(u8a)
        }
        return bb
    }

    //send
    let send = async(type, pkg, opt = {}) => {

        //headers
        let headers = get(opt, 'headers')
        if (!isobj(headers)) {
            headers = {}
        }
        // console.log('headers', headers)

        //dataType
        let dataType = get(opt, 'dataType', '')
        if (dataType !== 'blob' && dataType !== 'json') {
            dataType = 'blob'
        }
        // console.log('dataType', dataType)

        //cbProgress
        let cbProgress = get(opt, 'cbProgress')
        if (!isfun(cbProgress)) {
            cbProgress = () => {}
        }

        //cbProgressSafe, 交予axios之onUploadProgress/onDownloadProgress專用
        //why: 該兩個回呼由axios於其自己的堆疊上呼叫(axios/lib/helpers/progressEventReducer.js), 不在本函數之try涵蓋範圍內,
        //其同步拋錯直接成為uncaughtException而**殺掉整個node行程**(實測tmp/probe_r9_verify.mjs第3節);
        //而server側對應用端監聽器之拋錯早已由evEmit攔截並承諾「不會使伺服器行程崩潰」—— 同型能力只保護了一邊
        //sendDataSlice內之直接呼叫(cbProgressSlice/cbProgressMerge)不套用: 其落在呼叫鏈之promise內, 拋錯使該次promise reject而非殺行程
        //只報首次: 進度回呼於單次請求內會觸發數十至數百次, 逐次通報會把error通道灌爆; 「本次請求之進度回呼壞了」為一個事實, 故一則
        let bCbProgressErr = false
        let cbProgressSafe = (msg) => {
            try {
                cbProgress(msg)
            }
            catch (err) {
                if (!bCbProgressErr) {
                    bCbProgressErr = true
                    evEmit('error', `cbProgress error: ${getErrorMessage(err)}`)
                }
            }
        }

        //retry
        let retry = get(opt, 'retry')
        if (!isp0int(retry)) {
            retry = 1
        }

        //urlUse
        let urlUse = getUrlUse(type)

        //pm
        let pm = genPm()

        //dd, ct
        let dd = null
        let ct = {}
        if (dataType === 'blob') {

            //set ct
            ct = {
                'Content-Type': 'application/octet-stream',
            }

            //set dd
            dd = pkg

        }
        else if (dataType === 'json') {
            //axios預設會將物件自動轉換為JSON, 此處指定Content-Type且強制轉, 避免可能問題

            //set ct
            ct = {
                'Content-Type': 'application/json',
            }

            //JSON.stringify 須在 try 內: 呼叫端之 fileId 等含 BigInt 或循環參照時會拋, 原本於 try 外而以原生 TypeError 拒絕且 0 則事件,
            //同形狀之 execute 則為明確訊息 + 1 則(實測第十輪 A10); 屬 client 自身參數決定之失敗, 不進重試
            try {
                dd = JSON.stringify(pkg)
            }
            catch (err) {
                let msg = `input can not be serialized: ${getErrorMessage(err)}`
                evEmit('error', msg)
                return Promise.reject(msg)
            }
        }
        // console.log('dd', dd)

        //rt
        let rt = null
        if (env === 'nodejs') {
            if (type === 'download') {
                rt = 'stream' //nodejs download模式採用stream接收
            }
            else {
                rt = 'arraybuffer' //nodejs下沒有blob, 只能設定'json', 'arraybuffer', 'document', 'json', 'text', 'stream'
            }
        }
        else {
            if (type === 'download') {
                rt = 'blob' //瀏覽器使用blob下載
            }
            else {
                rt = 'blob' //瀏覽器使用blob取得資料
            }
        }
        // console.log('rt', rt)

        //downloadStream, 下載回應之處置: 以標頭協定(Return-Type / Return-Msg / Return-Retryable)判成敗, 成功者瀏覽器交出 Blob、nodejs 經 saveStreamToFile 落檔
        //離開前一律排空對端串流(drainResponse): 錯誤封包早返、落檔前置失敗(路徑逸出、symlink、mkdir)原本皆未排空(第十一輪 N8、B9、A2)
        let downloadStream = async(res) => {

            //returnType, returnMsg
            let returnType = get(res, `headers['return-type']`, '')
            let returnMsg = get(res, `headers['return-msg']`, '')

            //check, 伺服器於標頭標示Return-Retryable為false者為可證明不需重試之錯誤(下載路徑只讀標頭不解析本體; 瀏覽器跨域時若該標頭未被曝露則讀不到, 退回照常重試)
            if (returnType === 'error') {
                drainResponse(res)
                let returnRetryable = get(res, `headers['return-retryable']`, '')
                return Promise.reject(wrapNoRetry(returnMsg, returnRetryable !== 'false')) //事件由 callApi 之 attemptFailed 發
            }

            //filename, 自 Content-Disposition 解碼並淨化(見 decodeFilenameFromHeader)
            let filename = decodeFilenameFromHeader(get(res, `headers['content-disposition']`, ''))

            //streamRecv
            let streamRecv = get(res, 'data')

            //browser通過axios使用blob接收時會自動把串流接收並組合成blob, 此時streamRecv已是blob
            if (env === 'browser') {
                return { filename, bb: streamRecv }
            }

            //nodejs, 落檔(見 saveStreamToFile); 任何失敗皆先排空對端串流再向外拋(進入 send 之重試: 傳輸不穩須重試, 前提是失敗要被偵測到)
            try {
                return await saveStreamToFile(streamRecv, get(opt, 'fdDownload', ''), filename)
            }
            catch (err) {
                drainResponse(res)
                throw err
            }
        }

        //s
        let s = {
            method: 'POST',
            url: urlUse,
            data: dd,
            headers: {}, //於每次嘗試由 callApiCore 組裝(含每次重取之 token)
            timeout,
            maxContentLength: Infinity, //1024 * 1024 * 1024, Infinity //axios於nodejs中會限制內容大小故需改為無限
            maxBodyLength: Infinity, //1024 * 1024 * 1024, Infinity //axios於nodejs中會限制內容大小故需改為無限
            // decompress: true, //axios於nodejs中預設為true
            responseType: rt,
            onUploadProgress: function(ev) {
                //console.log('onUploadProgress', ev)

                //r
                let r = 0
                let loaded = ev.loaded
                let total = ev.total
                if (ispint(total)) {
                    r = (loaded * 100) / total
                }

                //cbProgress, 須經cbProgressSafe: 本回呼由axios於其自己之堆疊上呼叫, 拋錯即uncaughtException(見上方說明)
                cbProgressSafe({ prog: Math.floor(r), p: loaded, m: 'upload' })

            },
            onDownloadProgress: function (ev) {
                // console.log('onDownloadProgress', ev)

                //r
                let r = 0
                let loaded = ev.loaded
                // let total = ev.srcElement.getResponseHeader('Content-length') //若需要得知下載進度, 需於伺服器回傳時提供Content-length
                let total = ev.total
                if (ispint(total)) {
                    r = (loaded * 100) / total
                }

                //cbProgress, 須經cbProgressSafe: 本回呼由axios於其自己之堆疊上呼叫, 拋錯即uncaughtException(見上方說明)
                cbProgressSafe({ prog: Math.floor(r), p: loaded, m: 'download' })

            },
        }
        // console.log('s', s)

        //callApiCore, 處理axios成功then時訊息, catch時直接向外傳遞
        let callApiCore = async() => {

            //token, 每次嘗試重取(見 fetchToken), 拋錯或 reject 即為本次嘗試之失敗而進入重試
            //why: 原本於重試迴圈外只取一次 —— 重試沿用同一個 token, 「token 已過期、應用端之 getToken 已換發新 token」一類之 permission denied 其重試永遠無效;
            //而權限屬非同步系統、permission denied 須重試(專案重試原則), 重試要有意義就得帶上當下之 token。業界作法同: token 更新後以新 token 重送(axios-auth-refresh)
            //另 getToken 拋錯原本不重試, 與「凡請求失敗一律重試」不一致; getToken 之呼叫次數因此為 1+重試次數, 快取由應用端決定
            let token = await fetchToken()
            s.headers = {
                Authorization: `${tokenType} ${token}`,
                ...ct,
                ...headers,
            }

            //axios, catch時直接向外傳遞
            let res = await axios(s)

            //check, download時直接轉由downloadStream處理res, catch時直接向外傳遞
            if (type === 'download') {
                return await downloadStream(res)
            }

            //bb
            let bb = get(res, 'data')
            // console.log('bb', bb)

            //res2u8arr, catch時直接向外傳遞
            let u8a = await res2u8arr(bb)
            // console.log('u8a', u8a)

            //u8arr2obj, 以嚴格模式取狀態: 封包損毀(截斷、被中間層改寫)與「伺服器真的回了空物件」在寬鬆模式下都是{}, 分不出來也講不清楚
            let rd = u8arr2obj(u8a, { returnWithStateAndMsg: true })
            if (get(rd, 'state') !== 'success') {
                return Promise.reject(`invalid packet from server: ${get(rd, 'msg', 'unknown error')}`) //屬傳輸不穩, 依重試原則不標示不重試; 事件由 callApi 之 attemptFailed 發
            }
            let data = rd.msg
            // console.log('data', data)

            //check
            if (!iseobj(data)) {
                return Promise.reject(`data is not an effective object`)
            }

            //分離伺服器資料的success或error
            if (haskey(data, 'success')) {
                return Promise.resolve(data.success)
            }
            else if (haskey(data, 'error')) {
                return Promise.reject(wrapNoRetry(data.error, data.retryable)) //伺服器標示retryable為false者為可證明不需重試之錯誤(見wrapNoRetry); 事件由 attemptFailed 發
            }
            else {
                return Promise.reject(`data does not contain success or error`)
            }

        }

        //callApi, 處理callApiCore失敗catch時retry
        let callApi = async() => {

            //pmConvertResolve
            let fun = pmConvertResolve(callApiCore)

            //fun
            let r = await fun(s)
            // console.log('r', r)

            //while, 退避曲線見src/retryBackoff.mjs(其自有封頂, 使延遲不隨retry次數無界成長)
            let n = 0
            while (r.state === 'error') {

                //attemptFailed, 每次嘗試失敗恰一則事件(唯一擁有者, 見其說明)
                attemptFailed(r.msg)

                //check, 可證明不需重試者直接中止, 減少無效重試(413每次都重送整包本體)
                if (isNoRetry(r.msg)) {
                    break
                }

                //add
                n += 1

                //check
                if (n > retry) {
                    break
                }

                //delay
                let t = retryDelay(n)
                console.log(`wait ${dig(t / 1000, 1)}(second) to retry...`)
                await delay(t)

                //retry
                console.log(`retry n=${n}...`)
                r = await fun(s)
                // console.log(`retry n=${n} done`)

            }

            if (r.state === 'success') {
                return r.msg
            }
            else {
                return Promise.reject(unwrapNoRetry(r.msg)) //解包後呼叫端收到原始error值
            }
        }

        //callApi, 最終失敗值經 classifyFailure(伺服器回傳值原樣, 本地失敗取可讀訊息); 事件已於重試迴圈內逐次發出(attemptFailed), 此處不再發
        await callApi()
            .then((res) => {
                pm.resolve(res)
            })
            .catch((res) => {
                pm.reject(classifyFailure(res))
            })

        return pm
    }

    //sendPkg
    let sendPkg = async(type, data, cbProgress) => {
        //主要為中心化控制器使用, 通過execute進行上下傳數據

        //bb
        let bb = null
        try {

            //obj2u8arr, 以嚴格模式取狀態: 呼叫端給之input若含BigInt或循環參照, 寬鬆模式回空封包而伺服器會收到空物件並照常觸發execute事件,
            //呼叫端只會拿到不知所云之結果; 於此提早以明確訊息拒絕, 不送出
            let re = obj2u8arr(data, { returnWithStateAndMsg: true })
            if (get(re, 'state') !== 'success') {
                let msg = `input can not be serialized: ${get(re, 'msg', 'unknown error')}`
                evEmit('error', msg)
                return Promise.reject(msg)
            }
            let u8a = re.msg
            // console.log('u8a', u8a)

            //u8a to blob(in browser) or buffer(in nodejs)
            bb = u8arr2bb(u8a)
            // console.log('bb', bb)

        }
        catch (err) {
            return Promise.reject(err)
        }

        //send
        let res = await send(type, bb, { dataType: 'blob', retry: retryMain, cbProgress })

        return res
    }

    //calcHash
    let calcHash = async(inp) => {

        //bb, 一律包成 Blob 供 getFileXxHash 使用: 原本瀏覽器端直接交出 inp, 非 Blob 之位元組視圖即被 getFileXxHash 拒絕; Blob 包 Blob 不複製內容
        let bb = new Blob([inp])

        //hash
        let hash = await getFileXxHash(bb)

        return hash
    }

    //sendDataSlice
    let sendDataSlice = async(fileTotalName, bb, cbProgress) => {

        //cbProgress, 與send內同一道防呆: 本函數直接呼叫cbProgress(不經send), 故須自行給預設
        //why: send已對其opt.cbProgress補預設, 故execute與download省略cbProgress皆正常, 唯獨upload會於首片上傳完成時拋
        //「cbProgress is not a function」—— 三個公開方法對同一個選用參數行為不一致
        if (!isfun(cbProgress)) {
            cbProgress = () => {}
        }

        //cbProgress之保護: 進度回呼為**通知通道**, 其失敗不得決定上傳之成敗
        //why: 同一個cbProgress原有三種下場 —— 切片進度拋錯使該次promise reject(上傳失敗)、
        //bAllHash路徑拋錯亦使上傳失敗、而合併完成通知拋錯時例外沿.then傳到checkMerging之.catch(() => {})被吞掉,
        //pm遂**永不settle**, upload()永久懸置且0則事件、0行console(實測: 前者142ms reject, 後者12000ms未settle)
        //收斂為一種下場: 一律不影響結果, 只留一則error事件(與send內之cbProgressSafe同一規則, 見帳本R13)
        //只報首次: 切片進度於單次上傳內會觸發chunkTotal次, 逐次通報會把error通道灌爆
        let bCbProgErr = false
        let cbProgressRaw = cbProgress
        cbProgress = (msg) => {
            try {
                cbProgressRaw(msg)
            }
            catch (err) {
                if (!bCbProgErr) {
                    bCbProgErr = true
                    evEmit('error', `cbProgress error: ${getErrorMessage(err)}`)
                }
            }
        }

        //n, 輸入已由 upload 入口正規化為 Blob(含 File)或位元組視圖(見 normalizeUploadInput), 故大小只有兩種取法
        //why: 原本以 bb.size、bb.length 依序猜 —— ArrayBuffer 兩者皆無而取 1(雜湊卻以整個 ArrayBuffer 計, 應用端以 success 收到 1 byte),
        //多位元組型陣列之 length 為元素數而切出之位元組為其倍數(實測第十輪 D2)
        //nodejs用fs讀有檔案大小上限, 除非改傳入檔名用stream讀, 否則無法支援超大檔
        let n = cint(bb.byteLength !== undefined ? bb.byteLength : bb.size)
        // console.log('n', n)

        //fileTotalSize, 如實(空輸入即 0)
        //why: 原以 n = 1 假報大小以使切片數不為 0 —— 而伺服器去重以 fileSize === stats.size 比對, 1 !== 0 使空檔永不去重、每次皆重走切片與合併(第十一輪 N5);
        //切片數之下限另以 chunkTotal 表達
        let fileTotalSize = n

        //chunkTotal, 空輸入亦須送一片(0 byte)使伺服器有物可合併
        let chunkTotal = Math.max(1, Math.ceil(fileTotalSize / sizeSlice))
        // console.log('chunkTotal', chunkTotal)

        //progCount, progWeightSlice
        let progCount = 0
        let progWeightSlice = 0.99 //上傳階段進度使用99%
        // let progWeightMerge = 0.01 //合併階段進度使用1%

        //cbProgressSlice, 以累積機制計算進度, 累積片數配合總切片數量即可算出進度, 故不須輸入msg
        let cbProgressSlice = () => {
            progCount++
            let r = progCount / chunkTotal
            let prog = r * progWeightSlice * 100
            let psiz = r * fileTotalSize
            cbProgress({ prog, p: psiz, m: 'upload' })
        }

        //cbProgressMerge
        let cbProgressMerge = (msg) => {
            let perc = msg.prog
            let dir = msg.m
            if (dir === 'download' && perc === 100) {
                cbProgress({ prog: 100, p: fileTotalSize, m: 'upload' })
            }
        }

        //fileTotalHash
        // console.log(`calc hash for fileTotalSize[${fileTotalSize}]...`)
        let fileTotalHash = await calcHash(bb)
        // console.log(`calc hash for fileTotalSize[${fileTotalSize}] done`, fileTotalHash)

        //send check-total-hash
        // console.log(`send check-total-hash...`)
        let resUpCkt = await send('upload-controller', { mode: 'check-total-hash', fileHash: fileTotalHash, filename: fileTotalName, fileSize: fileTotalSize }, { dataType: 'json', retry: retryUpload })
        // console.log('resUpCkt', resUpCkt)
        //bAllHash: false:
        //  resUpCkt {
        //    path: 'uploadTemp\\2429b7ef08ce6ba9',
        //    bAllExist: false,
        //    bAllSize: false,
        //    bAllHash: false,
        //    bSls: true,
        //    slks: [
        //       0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11,
        //    ]
        //  }
        //bAllHash: true:
        //  resUpCkt {
        //    path: 'uploadTemp\\2429b7ef08ce6ba9',
        //    bAllExist: false,
        //    bAllSize: false,
        //    bAllHash: false,
        //    bSls: true,
        //    slks: [
        //       0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11,
        //    ],
        //    msg: {procUpload處理後的ro}
        //  }

        //check
        if (resUpCkt.bAllHash) {
            // console.log('已有上傳大檔')

            //cbProgressMerge
            cbProgressMerge({ prog: 100, m: 'download' }) //觸發上傳完畢後之下載回應, 故m須為download

            //resMg, 須回傳msg(也就是procUpload處理後的ro)
            let resMg = resUpCkt.msg

            return resMg
        }

        //check, 前後端sizeSlice須一致: 伺服器/slc以其sizeSlice為單次請求上限(超過即413), 且以切片檔大小是否等於sizeSlice判定切片完整(不等則永遠無法續傳),
        //故於此提早以明確訊息終止, 否則前端只會收到Payload Too Large而無從得知是組態不符; 舊版伺服器不回傳sizeSlice, 略過檢查以維持相容
        if (ispint(resUpCkt.sizeSlice) && resUpCkt.sizeSlice !== sizeSlice) {
            let msg = `sizeSlice mismatch: client[${sizeSlice}] and server[${resUpCkt.sizeSlice}] must be equal`
            evEmit('error', msg)
            return Promise.reject(msg)
        }

        //針對伺服器上已有切片檔案計算hash與比對
        if (resUpCkt.bSls) {
            // console.log('receive slks...', resUpCkt.slks[0], size(resUpCkt.slks))

            //fileSliceHashs
            let fileSliceHashs = []
            // let n = Math.max(resUpCkt.slks.length, 1)
            // let nr = Math.floor(n / 100)
            for (let k = 0; k < size(resUpCkt.slks); k++) {
                // if (k % nr === 0) {
                //     console.log(`calc hash for slices`, round(k / resUpCkt.slks.length * 100, 1), '%')
                // }

                //i
                let i = resUpCkt.slks[k]

                //start
                let start = i * sizeSlice

                //end
                let end = Math.min(start + sizeSlice, fileTotalSize)

                //chunk
                let chunk = bb.slice(start, end)

                //fileSliceHash
                let fileSliceHash = await calcHash(chunk)
                // console.log('fileSliceHash', fileSliceHash)

                //push
                fileSliceHashs.push({
                    i,
                    h: fileSliceHash,
                })

            }
            // console.log('fileSliceHashs', fileSliceHashs)

            //send check-slices-hash
            // console.log(`send check-slices-hash...`)
            let resUpCks = await send('upload-controller', { mode: 'check-slices-hash', fileHash: fileTotalHash, fileSliceHashs }, { dataType: 'json', retry: retryUpload })
            // console.log('resUpCks', resUpCks)
            // resUpCks {
            //   slks: [
            //      0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11,
            //     12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23,
            //     24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35,
            //     36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47,
            //     48, 49, 50, 51, 52, 53
            //   ]
            // }

            //update, 伺服器針對各切片計算hash與比對, 回傳resUpCks.slks代表hash一致的切片編號, 非一致hash的切片則須重傳, 尚未傳切片亦須繼續傳
            resUpCkt.slks = resUpCks.slks

        }

        //packageId
        let packageId = fileTotalHash
        // console.log('packageId', packageId)

        //upload slice
        // console.log(`upload slice...`)
        for (let i = 0; i < chunkTotal; i++) {

            //check
            if (resUpCkt.slks.indexOf(i) >= 0) {
                // console.log('已有上傳切片檔')
                cbProgressSlice() //直接觸發更新進度
                continue
            }

            //start
            let start = i * sizeSlice

            //end
            let end = Math.min(start + sizeSlice, fileTotalSize)

            //chunk
            let chunk = bb.slice(start, end)

            //hd
            let hd = { //用header傳key與value時, key不分大小寫, 故使用kebabCase
                'chunk-index': i,
                'chunk-total': chunkTotal,
                'package-id': packageId,
            }

            //send slice
            await send('slice', chunk, {
                headers: hd,
                dataType: 'blob',
                cbProgress: (msg) => {
                    // console.log('cbProgress', msg)
                    // let perc = msg.prog
                    // let dir = msg.m
                    // if (dir === 'upload' && perc === 100) {
                    // }
                },
                retry: retryUpload,
            })
            // console.log('resSl', resSl)

            //cbProgressSlice, 因其內有累加progCount, 實際代表是須成功傳輸後才能計算與回應外部進度, 故不能直接用於send的opt.cbProgress
            cbProgressSlice()

        }

        //send merge-slices-push
        // console.log(`send merge-slices-push...`)
        let resUpMgp = await send('upload-controller', { mode: 'merge-slices-push', fileHash: fileTotalHash, chunkTotal }, { dataType: 'json', retry: retryUpload })
        // console.log('resUpMgp', resUpMgp)

        //checkMerging
        let checkMerging = () => {
            let pm = genPm()

            //queueId
            let queueId = resUpMgp.queueId
            // console.log('queueId', queueId)

            //bBusy, 同一時間只允許一條查詢(含其重試鏈)在途
            //why: setInterval每2秒固定觸發, 前一次send若仍在retryUpload之退避重試中(伺服器回錯誤封包或斷線皆會進入重試)即再發一條, 多條重試鏈會疊加同時打伺服器,
            //每條鏈的每次重試都使伺服器重新觸發應用端upload事件(實測預設設定下12秒內觸發16次且遞增), 屬重試資源之多重濫用;
            //序列化後重試本身不變(每條send仍依retryUpload重試), 斷線續輪詢之設計亦不變, 只是前一條未結束前不再開新的一條
            let bBusy = false

            let t = setInterval(() => {

                //check
                if (bBusy) {
                    return
                }
                bBusy = true

                //send merge-slices-get
                // console.log(`send merge-slices-get...`)
                send('upload-controller', { mode: 'merge-slices-get', fileHash: fileTotalHash, filename: fileTotalName, queueId }, { dataType: 'json', retry: retryUpload })
                    .then((res) => {
                        // console.log('res', res)
                        // res => {
                        //   queueId,
                        //   state,
                        //   filename,
                        //   path,
                        //   msg: ...
                        // }

                        //check
                        if (res.state === 'success') {

                            //clearInterval
                            clearInterval(t)

                            //resolve, state為'success'時提取msg回傳
                            //settle須排在通知應用端之前(帳本R10之結構層): 原本 cbProgressMerge 排在前面, 而它會呼叫應用端之 cbProgress ——
                            //該回呼拋錯時, 例外沿 .then 傳到下方之 .catch(() => {}) 被吞掉, pm 遂**永不 settle**, upload() 永久懸置且 0 則事件、0 行 console
                            //(實測: 切片進度回呼拋錯為 142ms reject, 而合併完成回呼拋錯為 12000ms 未 settle —— 同一個 cbProgress 兩種下場)
                            pm.resolve(res.msg)

                            //cbProgressMerge
                            cbProgressMerge({ prog: 100, m: 'download' }) //觸發上傳完畢後之下載回應, 故m須為download

                        }
                        else if (res.state === 'error') {
                            // console.log('merge-slices-get error', res)

                            //clearInterval
                            clearInterval(t)

                            //reject, state為'error'時會於msg提供錯誤訊息; 為伺服器回之業務錯誤(HTTP 請求本身成功, 不經 callApi 之迴圈), 於此發事件(見 attemptFailed; 第十輪 F6 之站點)
                            attemptFailed(res.msg)
                            pm.reject(res.msg)

                        }

                    })
                    .catch(() => {
                        // console.log('merge-slices-get catch')
                        //可能發生網路斷訊錯誤, 不clearInterval, 持續輪循測試合併大檔之狀態; 此處不可console.log, 斷線期間每2秒會印一次
                    })
                    .finally(() => {
                        bBusy = false //本條查詢(含其重試鏈)已結束, 下一個tick才可再發
                    })

            }, 2000)

            return pm
        }

        //checkMerging
        let resMg = await checkMerging()
        // console.log('resMg', resMg)

        // console.log(`upload slice done`)
        return resMg
    }

    //execute
    let execute = async(func, input, cbProgress) => {

        //msg
        let msg = {
            // _mode: mode,
            // clientId,
            func,
            input,
        }

        //sendPkg
        let state = ''
        let res = null
        await sendPkg('basic', msg, cbProgress)
            .then((msg) => {
                // console.log('msg', msg)

                //check, 若為字串為錯誤訊息
                if (isestr(msg)) {
                    state = 'error'
                    res = msg
                    return
                }

                //check, 若為非物件為非預期錯誤
                if (!iseobj(msg)) {
                    console.log('msg is not an effective object', msg)
                    state = 'error'
                    res = 'msg is not an effective object'
                    return
                }

                //check, 若不存在output為非預期錯誤, msg格式為{func,input,output}但input會刪除
                if (!haskey(msg, 'output')) {
                    console.log('invalid msg.output', msg)
                    state = 'error'
                    res = 'invalid msg.output'
                    return
                }

                state = 'success'
                res = msg.output
            })
            .catch((msg) => {
                state = 'error'
                res = msg
            })

        //check
        if (state === '') {
            // console.log('invalid state', r)
            evEmit('error', `invalid state`)
            return Promise.reject('invalid state')
        }

        //check
        if (state === 'error') {
            // console.log('send data error', r)
            // evEmit('error', res) //一般錯誤會嘗試n次, 每次也都會emit, 故此處不再基於已知state='error'時再emit
            return Promise.reject(res)
        }

        return res
    }

    //upload
    let upload = async(filename, input, cbProgress) => {

        //bb, 輸入之正規化(見 normalizeUploadInput); 不支援者於入口拒絕, 不進入任何請求與重試(與 download 之 fdDownload 同一作法)
        let bb = normalizeUploadInput(input)
        if (bb === null) {
            let msg = `invalid input for upload: must be a Blob, File, ArrayBuffer, ArrayBufferView or string`
            evEmit('error', msg)
            return Promise.reject(msg)
        }

        return sendDataSlice(filename, bb, cbProgress)
    }

    //downloadNodejs
    let downloadNodejs = async(fileId, cbProgress, opt = {}) => {

        //check, fdDownload為nodejs下載之落地資料夾, 未給即必然失敗, 故於入口拒絕
        //why: 原無此檢核, 未給時直到downloadStream才以 fs.mkdirSync('') 拋 ENOENT(實測 tmp/probe_r9_hostile.mjs 之 E1),
        //而該處位於send之重試鏈內, 於是要重試retryDownload次(預設2, 含1.0s與1.78s退避)才失敗, 且訊息為ENOENT ——
        //呼叫端不會知道是自己沒給參數。本錯誤僅由client自身參數決定, 屬「可證明不需重試」, 於入口終止不進重試鏈
        //(同帳本R9: 公開方法之選用參數, 凡有直接使用之處, 該處須自行補預設或檢核; 原僅盤點cbProgress)
        let fdDownload = get(opt, 'fdDownload', '')
        if (!isestr(fdDownload)) {
            let msg = `invalid fdDownload for download in nodejs`
            evEmit('error', msg)
            return Promise.reject(msg)
        }

        //send download
        let msg = { fileId }
        let resMg = await send('download', msg, { ...opt, dataType: 'json', retry: retryDownload, cbProgress })
        // console.log('resMg', resMg)

        return resMg
    }

    //downloadBrowser
    let downloadBrowser = async(fileId, cbProgress, opt = {}) => {
        //交由瀏覽器下載與管理故無法監聽進度, 不使用cbProgress

        //downloadByManager
        let downloadByManager = get(opt, 'downloadByManager')
        if (!isbol(downloadByManager)) {
            downloadByManager = true
        }
        // console.log('downloadByManager', downloadByManager)

        if (downloadByManager) {
            //由瀏覽器的下載管理器下載, 使用get+stream

            //send download-get-filename
            let msg = { fileId }
            let resMg = await send('download-get-filename', msg, { dataType: 'json', retry: retryDownload })
            // console.log('resMg', resMg)

            //filename
            let filename = get(resMg, 'filename', '')
            // console.log('filename', filename)

            //token, 供組 URL(本路徑之下載由瀏覽器執行, token 只能走 query string); 經 fetchToken 取當下值
            //why 於 dwgfn 成功後才取: 此 token 是**下一個請求**(瀏覽器導覽至 /dwgf)要用的, 取值點須貼著使用點 ——
            //原本於 dwgfn 之前取, 中間隔了 dwgfn 之 1+retryDownload 次嘗試與其退避延遲(可達數秒), 短效 token 之應用端交給瀏覽器的是過期值(A 卷 §③-3.2)
            //拋錯屬入口失敗(不在 send 之重試鏈內), 一則事件 + 拒絕, 不原樣逸出; 處置留在呼叫點而不包進 fetchToken(另一呼叫點之處置為進重試, 兩者不同)
            let token = ''
            try {
                token = await fetchToken()
            }
            catch (err) {
                let msgErr = `getToken error: ${getErrorMessage(err)}`
                evEmit('error', msgErr)
                return Promise.reject(msgErr)
            }
            // console.log('token', token)

            //urlUse
            let urlUse = getUrlUse('download-get')
            // console.log('urlUse', urlUse)

            //url, fileId與token皆須encodeURIComponent: 含&或#會截斷query(#更會連token一起丟), 含+會被伺服器解析為空白(base64型token常見),
            //其他路由的token走Authorization header不受影響, 唯獨此處走query string
            let url = `${urlUse}?fileId=${encodeURIComponent(fileId)}&token=${encodeURIComponent(token)}`
            // console.log('url', url)

            //透過a元素打url下載, 讓瀏覽器認定為直接下載模式, 由瀏覽器展示下載進度與排入正在下載清單
            let a = document.createElement('a')
            a.href = url
            a.download = filename
            a.click()

            return filename
        }
        else {
            //通過axios下載得到blob, 回傳檔案名稱與blob供後續處理

            //send download
            let msg = { fileId }
            let resMg = await send('download', msg, { ...opt, dataType: 'json', retry: retryDownload, cbProgress })
            // console.log('resMg', resMg)

            return resMg
        }

    }

    //download
    let download = async(fileId, cbProgress, opt = {}) => {
        if (env === 'browser') {
            return downloadBrowser(fileId, cbProgress, opt)
        }
        else {
            return downloadNodejs(fileId, cbProgress, opt)
        }
    }

    //save
    ev.execute = execute
    ev.upload = upload
    ev.download = download

    return ev
}


export default WConverhpClient