WConverhpServer.mjs

import path from 'path'
import fs from 'fs'
import stream from 'stream'
import { validateHeaderValue } from 'http'
import Hapi from '@hapi/hapi'
import Inert from '@hapi/inert' //提供靜態檔案
import get from 'lodash-es/get.js'
import genPm from 'wsemi/src/genPm.mjs'
import evem from 'wsemi/src/evem.mjs'
import evEmitBase from 'wsemi/src/evEmit.mjs'
import evEmitDelayBase from 'wsemi/src/evEmitDelay.mjs'
import getErrorMessage from 'wsemi/src/getErrorMessage.mjs'
import iseobj from 'wsemi/src/iseobj.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isstr from 'wsemi/src/isstr.mjs'
import isp0int from 'wsemi/src/isp0int.mjs'
import ispint from 'wsemi/src/ispint.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import isfun from 'wsemi/src/isfun.mjs'
import ispm from 'wsemi/src/ispm.mjs'
import cint from 'wsemi/src/cint.mjs'
import cstr from 'wsemi/src/cstr.mjs'
import str2b64 from 'wsemi/src/str2b64.mjs'
import haskey from 'wsemi/src/haskey.mjs'
import obj2u8arr from 'wsemi/src/obj2u8arr.mjs'
import u8arr2obj from 'wsemi/src/u8arr2obj.mjs'
import fsIsFolder from 'wsemi/src/fsIsFolder.mjs'
import fsCreateFolder from 'wsemi/src/fsCreateFolder.mjs'
import fsDeleteFile from 'wsemi/src/fsDeleteFile.mjs'
import isSafeId from './isSafeId.mjs'
import sanitizeFilename from './sanitizeFilename.mjs'
import routeSpec from './routeSpec.mjs'
import encodeRfc5987 from './encodeRfc5987.mjs'
import mmg from './managerMergeSlices.mjs'
// import checkTotalHash from './checkTotalHash.mjs'
import checkTotalHash from './checkTotalHash.wk.umd.js'
// import checkSlicesHash from './checkSlicesHash.mjs'
import checkSlicesHash from './checkSlicesHash.wk.umd.js'


//=== 本檔專用之內部函式 =========================================================================
//以下九者原各自為一個單檔, 第十一輪依帳本 R20 併入: 判準為「唯有**重要且需獨立測試**之函數(如 managerMergeSlices),
//或**多檔共用**之函數, 才獨立成檔」。九者皆只被本檔使用、且皆無獨立單元測試, 故不具獨立成檔之理由。
//排列依相依順序: attempt → isValidHeaderValue → hasPipe → buildDownloadSource → readDownloadFields
//  → validDownloadField → destroyStreamRead → encodeOut → responseU8aStream / WithError → callApp
//仍獨立成檔者: isSafeId(三檔共用)、sanitizeFilename(兩檔共用 + 有單元測試)、encodeRfc5987 / routeSpec(有單元測試)、
//  managerMergeSlices(重要 + 有單元測試)、三個 worker(建置輸入)


/**
 * 觸碰應用端可控值之唯一入口
 *
 * 應用端可用會拋錯之getter或Proxy包裝其交給套件之值(觀測、記錄之常見手法), 而任何屬性讀取都可能拋錯:
 *   instanceof 會觸發 Proxy 之 getPrototypeOf trap;
 *   typed array 之 .length 對 Proxy receiver 會拋 Method get TypedArray.prototype.length called on incompatible receiver;
 *   stream.pipeline 建立時會讀來源之 pipe 等方法。
 * 這些例外若逸出 handler, hapi 只能回裸 HTTP 500, 且套件不發任何 error 事件, 應用端無從得知是自己給錯值。
 *
 * 本原語把每一次觸碰收斂為 tagged result, 使「公開訊息穩定、內部真因不丟」兩者兼得:
 *   成功回 { ok: true, value };失敗回 { ok: false, stage, cause }
 * stage 供組錯誤訊息, cause 為原始例外訊息 —— 公開訊息維持既有字面(invalid streamRead 等), 真因只進 error 事件供診斷
 *
 * 取因用 wsemi 之 getErrorMessage 而非 String(err): 後者對 toString 會拋錯之物件、對 null 與 undefined 皆拋錯,
 * 會使 catch 區塊自身拋出而讓例外仍逸出、外層 try 形同虛設。getErrorMessage 以候選階梯取值且整體包 try,
 * 契約上於任何輸入下皆不拋錯且回傳必為字串(wsemi 1.8.90 實測 25 種惡意輸入: 0 拋錯、0 非字串),
 * 但其取不到內容時回空字串(如 new Error()、已撤銷之 Proxy), 故此處另補非空退路
 *
 * @param {String} stage 輸入本次觸碰之階段名稱字串, 供錯誤訊息辨識
 * @param {Function} fn 輸入實際執行觸碰之函數
 * @returns {Object} 回傳 { ok: true, value } 或 { ok: false, stage, cause }
 */
function attempt(stage, fn) {
    try {
        return { ok: true, value: fn() }
    }
    catch (err) {
        let cause = getErrorMessage(err)
        return { ok: false, stage, cause: isestr(cause) ? cause : 'unknown error' }
    }
}


/**
 * 檢核值是否可作為HTTP回應標頭之值
 *
 * 以node之低階標頭驗證(同setHeader之判定, 禁CR/LF與其他控制字元、禁超出latin1範圍者)檢核應用端或請求端可控之標頭值
 *
 * why: 原只以isestr檢核, 含CR/LF或非latin1字元者交給hapi設定標頭時拋錯而回裸HTTP 500,
 * 非套件之錯誤封包, 前端無法解析且伺服器不發error事件
 *
 * @param {String} name 輸入標頭名稱字串
 * @param {*} value 輸入待檢核之標頭值
 * @returns {Boolean} 回傳是否為合法標頭值
 */
function isValidHeaderValue(name, value) {
    try {
        validateHeaderValue(name, value)
        return true
    }
    catch (err) {
        return false
    }
}


/**
 * 安全判定值是否像串流(有可呼叫之pipe)
 *
 * 回三態, 不回布林二態:
 *   true  確定像串流
 *   false 確定不像串流
 *   null  判定失敗(pipe為會拋錯之getter或Proxy trap) —— 其值本就不可用, 由呼叫端收斂為錯誤封包
 *
 * why 須區分 false 與 null: 「確定不是串流」者(如plain object)可走具體化路徑,
 * 而「讀不到」者不可當成「不是串流」而繼續處理, 否則後續之JSON.stringify等操作會再次觸發同一個拋錯getter
 *
 * why try 須包住屬性讀取本身: 應用端物件之 pipe 可為會拋錯之 getter 或 Proxy trap(帳本 R1)
 *
 * @param {*} v 輸入任意值
 * @returns {Boolean|null} 回傳三態判定
 */
function hasPipe(v) {
    try {
        return isfun(v.pipe)
    }
    catch (err) {
        return null
    }
}


/**
 * 將應用端交出之streamRead收斂為可交給hapi之回應本體, 並保證實送位元組數與fileSize一致
 *
 * why: 伺服器以fileSize寫Content-Length, 應用端給錯時hapi與node皆不會替套件擋:
 *   缺streamRead、非串流物件 → 本體不完整而連線懸置至前端閒置逾時(預設5分鐘, 再乘retryDownload); 宣告大於實送 → 同樣懸置;
 *   宣告小於實送 → 回200且前端存下被截斷之檔案並判成功(靜默毀損); stream-like、objectMode → hapi拒收回裸500且來源未被銷毀;
 *   已destroy之串流(如瀏覽器兩階段下載重用同一串流) → 懸置
 *
 * 作法: 可事前具體化者(Buffer、Uint8Array、字串、數值、布林、可JSON化物件, 皆為hapi現已接受之本體型別, 維持相容)先算實際長度,
 * 不符即回錯誤封包(標頭尚未送出);
 * 真串流則以計數串流(pipeline接於其後)包住: 超量立即以錯誤終止, 來源正常結束但不足則於flush產生錯誤 ——
 * 兩者皆令hapi中止回應(res.destroy), 前端收到不完整而失敗, 不會把壞檔當成功;
 * 來源自身出錯者沿用其錯誤不另報; pipeline亦使hapi於前端中斷時銷毀計數串流後連帶銷毀來源(維持原有收尾)
 *
 * 不採instanceof Readable之白名單: 會擋掉目前可正常下載之Buffer與plain object用法
 *
 * 四個階段(辨識 / 狀態讀取 / 建立pipeline / 具體化)全部經attempt: 應用端可用Proxy或拋錯之getter包裝其值,
 * 任一階段之屬性讀取皆可能拋錯而逸出成裸HTTP 500(見attempt之說明)
 *
 * 取捨(須知): 可事前具體化者由本函數以JSON.stringify產生bytes後交hapi, 故該route之json政策(replacer/space/suffix/escape)不套用於下載本體.
 * 代價是外部serverHapi若設有json.replacer(如移除敏感欄位), 對download本體不生效; 換得的是本體長度可於送標頭前確知並與fileSize比對.
 * 應用端若需套用自訂序列化政策, 應自行序列化後以字串或Buffer交出
 *
 * @param {*} streamRead 輸入應用端交出之本體來源
 * @param {Number} fileSize 輸入應用端宣告之位元組數(已經呼叫端以isValidFileSize檢核並以cint正規化)
 * @param {Function} funError 輸入長度不符時之回報函數, 參數為原因字串
 * @param {Object} [opt={}] 輸入設定物件, 預設{}
 * @param {Boolean} [opt.forHead=false] 輸入本次是否為HEAD請求布林值, 預設false。HEAD不送本體故不建計數串流, 直接交來源供hapi收尾
 * @returns {Object} 回傳 { source } 或 { error, reason };error為回前端之公開訊息, reason供error事件
 */
function buildDownloadSource(streamRead, fileSize, funError, opt = {}) {

    //forHead
    let forHead = get(opt, 'forHead', false) === true

    //check
    if (streamRead === undefined || streamRead === null) {
        return { error: 'invalid streamRead', reason: 'streamRead is null or undefined' }
    }

    //階段1 辨識, 階段2 狀態讀取: instanceof會觸發Proxy之getPrototypeOf trap, 三個狀態屬性亦可為會拋錯之getter, 故一併納入attempt
    let ri = attempt('identify streamRead', () => {
        if (!(streamRead instanceof stream.Readable)) {
            return { isReadable: false }
        }
        return {
            isReadable: true,
            objectMode: streamRead.readableObjectMode === true,
            done: streamRead.destroyed === true || streamRead.readableEnded === true,
        }
    })
    if (!ri.ok) {
        return { error: 'invalid streamRead', reason: `${ri.stage} failed: ${ri.cause}` }
    }

    //stream
    if (ri.value.isReadable) {

        //check
        if (ri.value.objectMode) {
            return { error: 'invalid streamRead', reason: 'streamRead is in object mode' }
        }
        if (ri.value.done) {
            return { error: 'invalid streamRead', reason: 'streamRead is already destroyed or ended (each download event must provide a new stream)' }
        }

        //check, HEAD不建pipeline, 直接交來源供hapi收尾
        if (forHead) {
            return { source: streamRead }
        }

        //階段3 建立pipeline: 其內部會讀來源之pipe等方法, Proxy可於此拋錯
        let rp = attempt('setup pipeline', () => {

            //counter, 計數並於不符時以錯誤終止
            let n = 0
            let bMismatch = false
            let counter = new stream.Transform({
                transform(chunk, encoding, cb) {
                    n += chunk.length
                    if (n > fileSize) {
                        bMismatch = true
                        cb(new Error(`streamRead sent more than fileSize[${fileSize}] bytes`))
                        return
                    }
                    cb(null, chunk)
                },
                flush(cb) {
                    if (n !== fileSize) {
                        bMismatch = true
                        cb(new Error(`streamRead ended at ${n} bytes but fileSize is ${fileSize}`))
                        return
                    }
                    cb()
                },
            })

            //pipeline, 任一方出錯或提前關閉皆銷毀雙方; 長度不符者另以error事件通知應用端, 來源自身出錯或前端中斷則不另報
            stream.pipeline(streamRead, counter, (err) => {
                if (err && bMismatch) {
                    funError(getErrorMessage(err))
                }
            })

            return counter
        })
        if (!rp.ok) {
            return { error: 'invalid streamRead', reason: `${rp.stage} failed: ${rp.cause}` }
        }

        return { source: rp.value }
    }

    //階段4 具體化: Buffer.isBuffer與instanceof皆可觸發Proxy trap, typed array之.length對Proxy receiver亦會拋
    //(實測 Method get TypedArray.prototype.length called on incompatible receiver), 故整段納入attempt
    let rm = attempt('materialize streamRead', () => {
        if (Buffer.isBuffer(streamRead)) {
            return { buf: streamRead }
        }
        if (streamRead instanceof Uint8Array) {
            return { buf: Buffer.from(streamRead) }
        }
        if (isstr(streamRead)) {
            return { buf: Buffer.from(streamRead, 'utf8') }
        }

        //number與object刻意不用wsemi之isnum與isobj(實測差異):
        //  isnum 收數字字串('42'為true)而拒NaN —— 本處需要的是「primitive number」之型別分派, 收字串會使字串走進數值分支
        //  isobj 拒陣列、Buffer、Uint8Array、Date —— 而陣列([1,2,3]得'[1,2,3]')為目前可正常下載且有測試鎖住之用法
        //字串與布林則已驗證與typeof完全一致, 故改用isstr與isbol
        if (typeof streamRead === 'number' || isbol(streamRead) || (typeof streamRead === 'object' && hasPipe(streamRead) === false)) {
            //number與boolean為hapi原生即接受之本體型別(其marshal以JSON序列化, 如42得'42'), 須維持相容;
            //物件則須先確認不像串流(hasPipe回false), 回null者代表其pipe為會拋錯之getter, 落到下方之不支援分支
            let s = null
            try {
                s = JSON.stringify(streamRead)
            }
            catch (err) {
                return { unserializable: getErrorMessage(err) } //取不到內容時回空字串, 下方以 isestr 分流故無須另補退路
            }
            if (typeof s !== 'string') {
                return { unserializable: '' }
            }
            return { buf: Buffer.from(s, 'utf8') }
        }
        return { unsupported: true }
    })
    if (!rm.ok) {
        return { error: 'invalid streamRead', reason: `${rm.stage} failed: ${rm.cause}` }
    }
    if (haskey(rm.value, 'unserializable')) {
        let d = rm.value.unserializable
        return { error: 'invalid streamRead', reason: isestr(d) ? `streamRead can not be serialized: ${d}` : 'streamRead can not be serialized' }
    }
    if (rm.value.unsupported === true) {
        return { error: 'invalid streamRead', reason: 'streamRead must be a readable stream, buffer, string, number, boolean or plain object' }
    }

    //check, buf為套件自建或已確認之Buffer, 此處讀length為套件自有值
    let buf = rm.value.buf
    if (buf.length !== fileSize) {
        return { error: 'fileSize mismatch', reason: `streamRead has ${buf.length} bytes but fileSize is ${fileSize}` }
    }

    return { source: buf }
}


/**
 * 讀取應用端download事件回傳物件之欄位
 *
 * 逐欄各自以attempt收斂, 使某欄之getter拋錯不影響不需該欄之路由:
 *   /dwgfn 只需 streamRead 與 filename, 若一併讀 fileSize 而其getter拋錯, 該路由會由成功變失敗(行為改變)
 *
 * 失敗時**一併回傳已成功讀到之欄位**(fields), 使呼叫端能清理已取得之資源
 *
 * why: streamRead 一律排在 keys 之首, 故凡後續欄位之 getter 拋錯者, 該串流**已經在套件手上**。
 * 原本只回 { ok:false, field, cause } 而不回 fields, 路由層遂無從清理 —— 應用端交出之串流(常為 fs.createReadStream)
 * 就此失去引用且未被 destroy, 其 fd 持續開啟。實測(tmp/probe_r9_rest.mjs 第1節): 四欄之中後三欄拋錯時
 * 串流皆為 destroyed=false。三條下載路由原有之註解「讀取失敗時尚未取得串流引用, 無從清理」僅對「首欄即拋錯」成立
 *
 * @param {Object} r 輸入應用端download事件所resolve之物件
 * @param {Array} keys 輸入本次需要之欄位名稱陣列
 * @returns {Object} 回傳 { ok: true, fields } 或 { ok: false, field, cause, fields };後者之 fields 為失敗前已讀到者
 */
function readDownloadFields(r, keys) {
    let fields = {}
    for (let k of keys) {
        let a = attempt(`read field[${k}]`, () => {
            return get(r, k)
        })
        if (!a.ok) {
            return { ok: false, field: k, cause: a.cause, fields } //fields為已成功讀到者, 供呼叫端清理
        }
        fields[k] = a.value
    }
    return { ok: true, fields }
}


/**
 * 檢核應用端給之fileSize是否可用
 *
 * fileSize有兩個用途, 兩者決定了值域:
 *   1. 原樣(經cint正規化後)寫入Content-Length —— HTTP之訊息分框依據
 *   2. 與實際位元組數比較(可事前具體化者比 buf.length, 串流則於計數之flush比) —— 故比較前須先以cint轉為數值
 *
 * why 須為安全整數: 原以lodash isNumber檢核, 其對NaN、Infinity、負數、小數皆回true, 此類值交給node寫標頭即拋錯,
 * hapi於送標頭階段失敗只能直接斷線, 前端連回應標頭都收不到而伺服器亦不發error事件;
 * 另超出安全整數者(如Number.MAX_SAFE_INTEGER+1或1e21)雖為整數, 但其字串形式帶指數記號(如'1e+21'),
 * 不合Content-Length之1*DIGIT語法, 實測同樣是連線懸置且無回應標頭
 *
 * 採wsemi之isp0int搭配useLimitSafe, 故數字字串(如'1058915')亦視為有效, 呼叫端須以cint正規化後才可用於比較與寫標頭(帳本R4b)
 *
 * @param {*} v 輸入任意值
 * @returns {Boolean} 回傳是否為可用之fileSize
 */
function isValidFileSize(v) {
    return isp0int(v, { useLimitSafe: true })
}


//toWellFormed, 以 U+FFFD 取代孤立代理對
let toWellFormed = (s) => {
    if (typeof s.toWellFormed === 'function') {
        return s.toWellFormed()
    }
    return s.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '�')
}


/**
 * 判定並正規化應用端 download 事件回傳之單一欄位(filename、fileSize、fileType)
 *
 * why 判定本身須在 attempt 內: 欄位之**擷取**已由 readDownloadFields 收進 attempt, 而擷取到的值之**型別判定**原本在其外 ——
 * wsemi 之 isestr / isValidFileSize 皆經 Object.prototype.toString.call, 會觸發值之 Symbol.toStringTag getter;
 * 該 getter 拋錯時三條下載路由皆回裸 HTTP 500 且 0 則事件, /dw 之應用端串流且未被銷毀(實測第十輪 A5, 7 格)。與帳本 R1 之 #32 同型。
 *
 * why 須正規化為字串基本型: 通過 isestr 之物件(帶 Symbol.toStringTag='String')其 toString 可拋錯, 原本 /dwgfn 有 attempt + cstr 而 /dw、/dwgf 無 ——
 * 同一個 filename 於 /dwgfn 回錯誤 + 1 則事件, 於另兩路由靜默送出空檔名(實測第十輪 A6)。收斂於此, 三路由同一判定。
 *
 * why filename 須取代孤立代理對: /dw 以 wsemi 之 str2b64 編碼, 其寬鬆模式吞掉 URIError 而回空字串, 檔名整個消失;
 * /dwgf 之 encodeRfc5987 則以 U+FFFD 取代(實測第十輪 A7)。正規化於此, 兩種編碼器得到同一個檔名。
 *
 * @param {String} name 輸入欄位名稱, 為 'filename'、'fileSize' 或 'fileType'
 * @param {*} v 輸入應用端交出之欄位值
 * @returns {Object} 回傳 { ok, value, cause }: ok 為是否可用; value 為正規化後之值(fileSize 為數值, 其餘為字串基本型); cause 為判定時拋出之原因(未拋錯者為空字串)
 */
function validDownloadField(name, v) {
    let a = attempt(`check field[${name}]`, () => {
        if (name === 'fileSize') {
            if (!isValidFileSize(v)) {
                return { ok: false }
            }
            return { ok: true, value: cint(v) } //isValidFileSize 亦接受數字字串, 須正規化為數值後才可寫 Content-Length 並與實送位元組數以 === 比較
        }
        if (name === 'filename' || name === 'fileType') {
            if (!isestr(v)) {
                return { ok: false }
            }
            let s = cstr(v) //toString 拋錯者 cstr 回空字串
            if (!isestr(s)) {
                return { ok: false }
            }
            if (name === 'fileType') {
                if (!isValidHeaderValue('Content-Type', s)) {
                    return { ok: false }
                }
                return { ok: true, value: s }
            }
            return { ok: true, value: toWellFormed(s) }
        }
        throw new Error(`unknown field[${name}]`)
    })
    if (!a.ok) {
        return { ok: false, value: undefined, cause: a.cause }
    }
    return { ok: a.value.ok, value: a.value.value, cause: '' }
}


/**
 * 清理應用端交出之串流
 *
 * 只對「像串流(有pipe)且可destroy」者呼叫, 不對任意值(Buffer、字串、數值、plain object)盲呼叫destroy
 *
 * why try須包住屬性讀取本身而非只包v.destroy(): 應用端物件之pipe/destroy可能是會拋錯之getter或Proxy trap,
 * 只包v.destroy()時該例外會逸出handler, hapi只能回裸HTTP 500且伺服器不發error事件 —— 與本函數要收斂之情形同型
 *
 * 本函數不回報成敗: 清理為盡力而為, 失敗不應改變呼叫端之錯誤處置流程
 *
 * @param {*} v 輸入應用端交出之值
 */
function destroyStreamRead(v) {
    try {
        if (v && isfun(v.pipe) && isfun(v.destroy)) {
            v.destroy()
        }
    }
    catch (err) {}
}


/**
 * 正規化協定鍵之值, 使該鍵不可能於序列化時消失
 *
 * why: JSON對值為undefined、function、symbol之鍵是「靜默丟棄整個鍵」而非拋錯
 * (見wsemi之obj2stru8arr檔頭所列失真集合), 故以編碼器對 { success: undefined } 編碼會回 state 為 success
 * 但解回 {} —— 連success鍵都不存在。前端只能以無意義之'data does not contain success or error'拒絕,
 * 而伺服器亦不發任何error事件, 應用端無從得知
 *
 * 於包入協定外殼前先把這三種值轉為null(與procApp對output已有之處置一致), 協定鍵遂於結構上不可能消失,
 * 不需encode後再decode回來檢查(大輸出之代價加倍)
 *
 * @param {*} v 輸入協定鍵之值
 * @returns {*} 回傳可安全序列化之值
 */
function canonProtocolValue(v) {
    if (v === undefined || isfun(v) || typeof v === 'symbol') {
        return null
    }
    return v
}


/**
 * 序列化回應封包之唯一出口
 *
 * 兩道保護:
 *   1. 協定鍵之值先經canonProtocolValue正規化, 使該鍵於結構上不可能因JSON之靜默失真而消失
 *   2. 以wsemi之嚴格模式取狀態, 值無法序列化者(如含BigInt、循環參照)回null並呼叫funError
 *
 * why 不用「encode後再decode回來檢查協定鍵」: 該作法對大輸出之代價加倍, 而第1點已於結構上保證
 *
 * @param {Object} out 輸入待序列化之封包物件, 其鍵為協定鍵(success或error)
 * @param {Function} funError 輸入序列化失敗時之回報函數, 參數為原因字串
 * @returns {Uint8Array|null} 回傳序列化結果; 失敗時回null(已呼叫funError)
 */
function encodeOut(out, funError) {

    //canon, 協定鍵之值先正規化, 使其不因JSON之靜默失真而消失
    let o = {}
    for (let k of Object.keys(out)) {
        o[k] = canonProtocolValue(out[k])
    }

    let r = obj2u8arr(o, { returnWithStateAndMsg: true })
    if (get(r, 'state') !== 'success') {
        funError(get(r, 'msg', 'unknown error'))
        return null
    }

    return r.msg
}


/**
 * 以octet-stream回應二進位封包, 並附上本套件之回應協定標頭
 *
 * 本套件之回應協定有兩層: 本體(obj2u8arr之success/error)與標頭(Return-Type、Return-Msg、Return-Retryable、Content-Disposition)。
 * 下載路徑(client之downloadStream)只讀標頭不解析本體, 故標頭是協定的一部分而非附屬資訊
 *
 * Return-Msg之值域檢核(見isValidHeaderValue):
 * 錯誤訊息可能回顯請求端可控之字串(如 invalid mode[<mode>] in payload), 該值若含CR/LF或超出latin1範圍(如中文),
 * node於寫標頭時拋錯而hapi只能回裸HTTP 500 —— 非本套件之錯誤封包, 前端無法解析且伺服器不發error事件。
 * 故不合法者**不送該標頭**而非讓整個回應失敗: 完整訊息仍在本體之error封包內, 前端解析本體即可取得;
 * 下載路徑雖只讀標頭, 但其錯誤訊息皆為套件自產之固定字串, 不受影響
 *
 * @param {Object} res 輸入hapi之response toolkit
 * @param {Uint8Array} u8a 輸入待回應之二進位封包
 * @param {Object} [opt={}] 輸入設定物件, 預設{}
 * @param {String} [opt.returnType=''] 輸入Return-Type標頭值, 為'success'或'error', 空字串代表不送
 * @param {String} [opt.returnMsg=''] 輸入Return-Msg標頭值, 空字串或非法標頭值代表不送
 * @returns {Object} 回傳hapi之response物件
 */
function responseU8aStream(res, u8a, opt = {}) {

    //stream
    let smr = new stream.Readable()
    smr._read = () => {}
    smr.push(u8a)
    smr.push(null)

    //returnType
    let returnType = get(opt, 'returnType', '')

    //returnMsg
    let returnMsg = get(opt, 'returnMsg', '')

    //r
    let r = res.response(smr)
        .header('Cache-Control', 'no-cache, no-store, must-revalidate')
        .header('Content-Type', 'application/octet-stream')
        .header('Content-Length', smr.readableLength)
    if (isestr(returnType)) {
        r.header('Return-Type', returnType)
    }

    //check, 標頭值須通過值域檢核; 不合法者略過該標頭而非使整個回應失敗(完整訊息仍在本體)
    if (isestr(returnMsg) && isValidHeaderValue('Return-Msg', returnMsg)) {
        r.header('Return-Msg', returnMsg)
    }

    return r
}


/**
 * 以本套件之錯誤封包回應
 *
 * 本函數為所有失敗之最終出口: 其自身之序列化不再以嚴格模式取狀態, 因為輸入之msg恆為套件自產字串
 * (全部呼叫點皆為字面或模板字串), 且此處若再失敗亦無處可退(帳本R3之刻意不套站點)
 *
 * why 與 responseU8aStream 並存而非合一: 前者之本體由呼叫端算好(可能是成功結果), 後者之本體由本函數依 msg 產生 ——
 * 輸入不同, 合一會多一個「這次是不是錯誤」的旗標參數
 *
 * @param {Object} res 輸入hapi之response toolkit
 * @param {String} msg 輸入錯誤訊息字串
 * @param {Object} [opt={}] 輸入設定物件, 預設{}
 * @param {Boolean} [opt.retryable=true] 輸入是否可重試布林值, 預設true。
 * 給false者限「可證明不需重試」之錯誤: 結果僅由client自行建構之請求內容(mode、fileHash、chunkTotal、chunkIndex、packageId、fileId)決定, 重送同一請求必得同一結果;
 * 凡涉及權限(permission denied)、應用端reject、應用端回傳形狀不合、磁碟、網路者皆為狀態不穩, 不得標示, 依重試原則由前端照常重試。
 * 標示同時置於封包(供execute/upload/dwgfn之本體解析)與標頭Return-Retryable(供download之串流路徑, 該路徑只讀標頭不解析本體)
 * @returns {Object} 回傳hapi之response物件
 */
function responseU8aStreamWithError(res, msg, opt = {}) {

    //check
    if (!isestr(msg)) {
        console.log('msg', msg)
        console.log(`msg is not an effective string, set msg=''`)
        msg = ''
    }

    //retryable
    let retryable = get(opt, 'retryable', true)

    //out
    let out = {
        error: msg,
    }
    if (retryable === false) {
        out.retryable = false
    }

    //u8aOut
    let u8aOut = obj2u8arr(out)

    //r
    let r = responseU8aStream(res, u8aOut, { returnType: 'error', returnMsg: msg })
    if (retryable === false) {
        r.header('Return-Retryable', 'false')
    }

    return r
}


/**
 * 派發一次應用端呼叫, 並保證該次呼叫必定終結
 *
 * why: 本套件之路由層刻意關閉 timeout.server 與 timeout.socket(大檔傳輸本就超過任何固定值),
 * 宿主之兜底因此不存在。無人接聽時該 pm 永無人 settle, 請求即**永久懸置**且 0 則 error 事件
 * (實測 tmp/probe_r9_hang.mjs: /main、/dw、/ulctr 三入口皆 6000ms 未回應)。
 * 而該事實其實早就在套件手上 —— eventemitter3 之 emit 對無監聽器回 false, 只是被丟棄。
 *
 * why 於派發前以 listenerCount 判定, 而非取 evEmit 之回傳值:
 * wsemi 之 evEmit 對「無監聽器」與「監聽器同步拋錯」**皆回 false**(其 evEmit.mjs:107 之 catch 分支),
 * 兩者不可分辨(實測 tmp/probe_r9_emitret.mjs)。而拋錯那格已由 evEmit 之 funSettle 拒絕過 pm、
 * 且已發過一則 error 事件 —— 以回傳值判定會對該格**再發一則**, 即同一次失敗兩則(違反規則帳本 R5)。
 * 派發前判定則兩者分明, 且不受「監聽器於執行中移除自己」影響(判定早於執行)。
 *
 * 本函數只處理「**現在沒有人接聽**」這一種狀態。應用端接了卻不回話(忘記 settle pm、
 * resolve 一個永不 settle 之 promise、verifyConn 回 pending promise)一律**不在本函數職責內** ——
 * 那是對無限未來之斷言, 任何有限時點皆與「還沒好」不可分辨, 屬呼叫端責任。判準與否決過的修法見帳本 R12 之分界線。
 *
 * @param {Object} ev 輸入事件物件, 為 wsemi 之 evem 所建立之 eventemitter3 實例
 * @param {Function} evEmit 輸入本套件之派發函數, 簽章為 (name, ...args)
 * @param {String} name 輸入事件名稱字串
 * @param {Array} args 輸入事件參數陣列(不含 pm)
 * @param {Object} pm 輸入本次呼叫之回覆通道, 會作為事件之最後一個參數交給監聽器
 * @param {Function} funError 輸入無人接聽時之回報函數, 參數為原因字串
 * @returns {Boolean} 回傳是否已派發; false 代表無人接聽且 pm 已被拒絕
 */
function callApp(ev, evEmit, name, args, pm, funError) {

    //check, 無人接聽即終結: 一次失敗恰一則事件(帳本 R5), 且不得懸置
    if (ev.listenerCount(name) === 0) {
        let msg = `no listener for event[${name}]`

        //settle 須排在回報之前(帳本 R10 之結構層): pm.reject 為此處唯一「非做不可」之事,
        //funError 會走到應用端之 error 監聽器, 其若拋錯而排在前面, 該次請求就永遠不會被 settle ——
        //那正是本模組存在所要防止的懸置。順序即保證, 不可對調
        pm.reject(msg)
        funError(msg)

        return false
    }

    //emit, pm 為事件之最後一個參數(本套件之契約)
    evEmit(name, ...args, pm)

    return true
}

//=== 本檔專用之內部函式結束 =====================================================================


//回傳前端stream時(POST或GET皆可), 前端會須等stream傳完才能判斷是否為大檔或錯誤訊息, 此會導致若回傳超大檔, 會需要對超大檔進行解析會有記憶體上限問題, 故需要通過header提供基本成功或失敗訊息, 讓前端能進行解析判斷
//回傳前端(nodejs)時, 針對超大檔, 只能用POST並用stream回傳
//回傳前端(browser)時, 針對超大檔, 可用POST並用stream回傳但還要處理進度條, 若要交由瀏覽器下載器處理, 只能用GET並用stream回傳, 且前端只能用window.location.href或a.href+a.click()下載


/**
 * 建立Hapi伺服器
 *
 * @class
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {Integer} [opt.port=8080] 輸入Hapi伺服器所在port正整數,預設8080,須為1至65535。埠被占用等啟動失敗時不拋出,以error事件通知。本套件各整數選項(port、sizeSlice、sizeMsg、delayForSlice)須為安全整數(Number.isSafeInteger),Infinity與超出安全範圍者(如Number.MAX_SAFE_INTEGER+1)一律視為無效取預設;另各依其用途有值域上限(port為65535、delayForSlice為計時器上限2147483647),超過者亦取預設
 * @param {Boolean} [opt.useInert=true] 輸入是否提供瀏覽pathStaticFiles資料夾內檔案之布林值,預設true
 * @param {String} [opt.pathStaticFiles='dist'] 輸入當useInert=true時提供瀏覽資料夾字串,預設'dist'
 * @param {String} [opt.pathUploadTemp='./uploadTemp'] 輸入暫時存放切片上傳檔案資料夾字串,預設'./uploadTemp'。資料夾內除切片與合併檔外另有狀態標記檔(<fileHash>.done為合併完成、<fileHash>.error為合併失敗、<fileHash>.q<隊列>.ro為該次上傳已由upload事件處理完成之結果),為合併佇列之狀態載體,前端重送查詢時據以回傳相同結果而不重複觸發upload事件;上傳可能仍在進行時不得刪除,應用端可依修改時間清理確定已結束者
 * @param {String} [opt.apiName='api'] 輸入API名稱字串,預設'api'
 * @param {String} [opt.tokenType='Bearer'] 輸入token類型字串,預設'Bearer'
 * @param {Integer} [opt.sizeSlice=1024*1024] 輸入切片上傳檔案之切片檔案大小整數,單位為Byte,預設為1024*1024。須與前端之sizeSlice一致,伺服器以此為單一切片請求(/slc)之本體上限並據以判定切片是否完整,check-total-hash會回傳此值供前端比對,不一致時前端upload會以sizeSlice mismatch訊息終止
 * @param {Integer} [opt.sizeMsg=100*1024*1024] 輸入單次請求本體大小上限整數,單位為Byte,預設為100*1024*1024。適用於除切片上傳(/slc)外之各API(/main、/ulctr、/dwgfn、/dw),此類請求須將整個本體讀入記憶體,超過上限會回應413且不觸發execute/upload/download事件(請求帶Content-Length者於進入路由前即被拒,亦不觸發verifyConn與handler事件;無Content-Length之chunked本體則須讀入後才能判定,故verifyConn與handler事件已觸發);切片上傳之單次請求上限為sizeSlice,大檔案總大小不受此限制,請改用upload
 * @param {Function} [opt.verifyConn=()=>{return true}] 輸入呼叫API時檢測函數,預設()=>{return true}
 * @param {Array} [opt.corsOrigins=['*']] 輸入允許跨域網域陣列,若給予['*']代表允許全部,預設['*']。回應一律以Access-Control-Expose-Headers曝露Return-Type、Return-Msg、Return-Retryable、Content-Disposition四個標頭,使前端(browser)與API不同源時download仍可讀取成敗與檔名
 * @param {Integer} [opt.delayForSlice=100] 輸入切片上傳檔案API用延遲響應時間,單位ms,預設100
 * @param {Boolean} [opt.serverHapi=null] 輸入外部提供Hapi伺服器物件,預設null。外部提供者須自行於其routes.cors設定additionalExposedHeaders含Return-Type、Return-Msg、Return-Retryable、Content-Disposition,否則前端(browser)與API不同源時download會失效
 * @returns {Object} 回傳事件物件,可監聽事件execute、upload、download、handler、error。upload事件之input為{from,filename,filenameSafe,path}:from為'merge-slices-get'(切片合併完成)或'check-total-hash'(整檔已存在之去重),path為合併檔之伺服器絕對路徑;**filename為用戶端所給之原值、未經淨化、不可信**(可含路徑分隔符、`..`、各平台非法字元、保留裝置名,亦可為非字串),供顯示或保留目錄結構(如瀏覽器webkitdirectory之相對路徑);**filenameSafe**為其淨化值(只取最末路徑段、去除非法字元與保留裝置名,無可用檔名時為空字串),應用端以filename組落地路徑前須自行驗證,或直接改用filenameSafe。**監聽器須為同步函數,不可為async函數、亦不可回傳promise**:事件派發依EventEmitter規範丟棄監聽器之回傳值,其rejection無人觀察,於nodejs即unhandledRejection而使整個行程崩潰;非同步結果一律以事件所帶之pm回覆(pm即本套件提供之回覆通道,監聽器不需要第二條),寫法為`wo.on('upload', (input, pm) => { doWork().then(pm.resolve, pm.reject) })`。監聽器之同步拋錯則由套件攔截:以error事件通知,該請求以錯誤回應,不會使伺服器行程崩潰。execute與upload事件之回傳值須能序列化(不可含BigInt或循環參照,此類值會使整包無法編碼),不能者回錯誤封包並發error事件;download事件須resolve物件{streamRead,filename,fileSize,fileType}:streamRead為非objectMode之可讀串流(Buffer、Uint8Array、字串、數值、布林、可JSON化物件亦可,後數者由套件以JSON.stringify具體化後交出,故route之json政策replacer/space/suffix不套用於下載本體,需自訂序列化者請自行序列化後以字串或Buffer交出),fileSize須為安全非負整數且等於實際位元組數(伺服器據以寫Content-Length,串流實送不符時以錯誤中止回應並發error事件使前端失敗,不會把不完整檔案當成功;Buffer等可事前具體化者不符則直接回錯誤封包),fileType須為合法標頭值;欄位缺漏或值非法一律回錯誤封包並發error事件,不會懸置或回500。fileSize為0之空檔以HTTP 200與Content-Length:0回應(不採hapi預設之204,否則瀏覽器下載管理器會將下載標記為取消)。下載回應帶Content-Encoding:identity而不壓縮,使Content-Length得以保留供前端計算下載進度。瀏覽器下載管理器路徑(downloadByManager=true)對同一fileId會觸發兩次download事件(第一次僅取檔名並銷毀串流),每次皆須交出新串流。回傳之物件另帶stop方法:其回傳promise供等待伺服器真正停止(`await wo.stop()`),該promise恆resolve,停止失敗以error事件通知而不外拋。**應用端未註冊某事件之監聽器時,該事件之請求會立即以錯誤封包回應並發一則error事件**(不會等待一個不存在的回覆);而註冊了監聽器卻未呼叫pm者屬呼叫端自身之疏漏,套件不代為偵測。錯誤回應一律為HTTP 200並以Return-Type:error標頭與錯誤封包表達,唯瀏覽器下載管理器所用之GET下載路由(dwgf)以非2xx狀態碼表達(權限403、參數400、應用端無法提供檔案404、應用端交出之內容不合契約500),使瀏覽器顯示下載失敗而不把錯誤內容存成檔案。回前端之錯誤訊息不含伺服器路徑與底層細節,細節一律以error事件通知
 * @example
 *
 * import fs from 'fs'
 * import _ from 'lodash-es'
 * import w from 'wsemi'
 * import WConverhpServer from './src/WConverhpServer.mjs'
 *
 * let ms = []
 *
 * let opt = {
 *     port: 8080,
 *     apiName: 'api',
 *     pathStaticFiles: '.', //要存取專案資料夾下web.html, 故不能給dist
 *     sizeMsg: 100 * 1024 * 1024, //單次請求本體上限, 預設100mb, 適用於除切片(/slc)外之各API; 切片單次上限為sizeSlice, 大檔案總大小不受限, 請改用upload
 *     verifyConn: async ({ apiType, authorization, query, headers, req }) => {
 *         console.log('verifyConn', `apiType[${apiType}]`, `authorization[${authorization}]`)
 *         let token = w.strdelleft(authorization, 7) //刪除Bearer
 *         if (!w.isestr(token)) {
 *             return false
 *         }
 *         // await w.delay(3000)
 *         return true
 *     },
 * }
 *
 * //new
 * let wo = new WConverhpServer(opt)
 *
 * wo.on('execute', (func, input, pm) => {
 *     // console.log(`Server[port:${opt.port}]: execute`, func, input)
 *     console.log(`Server[port:${opt.port}]: execute`, func)
 *
 *     try {
 *
 *         if (func === 'add') {
 *
 *             if (_.get(input, 'p.d.u8a', null)) {
 *                 console.log('input.p.d.u8a', input.p.d.u8a)
 *                 ms.push({ 'input.p.d.u8a': input.p.d.u8a })
 *             }
 *
 *             let r = {
 *                 _add: input.p.a + input.p.b,
 *                 _data: [11, 22.22, 'abc', { x: '21', y: 65.43, z: 'test中文' }],
 *                 _bin: {
 *                     name: 'zdata.b2',
 *                     u8a: new Uint8Array([52, 66, 97, 115]),
 *                 },
 *             }
 *
 *             pm.resolve(r)
 *
 *         }
 *         else {
 *             console.log('invalid func')
 *             pm.reject('invalid func')
 *         }
 *
 *     }
 *     catch (err) {
 *         console.log('execute error', err)
 *         pm.reject('execute error')
 *     }
 *
 * })
 * wo.on('upload', (input, pm) => {
 *     console.log(`Server[port:${opt.port}]: upload`, input)
 *
 *     try {
 *         ms.push({ 'receive and return': input })
 *         let output = input
 *         pm.resolve(output)
 *     }
 *     catch (err) {
 *         console.log('upload error', err)
 *         pm.reject('upload error')
 *     }
 *
 * })
 * wo.on('download', (input, pm) => {
 *     console.log(`Server[port:${opt.port}]: download`, input)
 *
 *     let streamRead = null
 *     try {
 *         ms.push({ 'download': input })
 *
 *         //fp
 *         let fp = `./test/1mb.7z`
 *
 *         //check, 檔案存在才往下
 *
 *         //fileSize
 *         let stats = fs.statSync(fp)
 *         let fileSize = stats.size
 *
 *         //streamRead
 *         streamRead = fs.createReadStream(fp)
 *
 *         //filename
 *         let filename = `1mb中文.7z` //測試支援中文
 *
 *         //fileType
 *         let fileType = 'application/x-7z-compressed'
 *
 *         //output
 *         let output = {
 *             streamRead,
 *             filename,
 *             fileSize,
 *             fileType,
 *         }
 *
 *         pm.resolve(output)
 *     }
 *     catch (err) {
 *         console.log('download error', err)
 *         // try {
 *         //     streamRead.destroy() //若fs.createReadStream早於fs.statSync執行, 但fs.statSync發生錯誤時, stream得要destroy
 *         // }
 *         // catch (err) {}
 *         pm.reject('download error')
 *     }
 *
 * })
 * wo.on('error', (err) => {
 *     console.log(`Server[port:${opt.port}]: error`, err)
 * })
 * wo.on('handler', (data) => {
 *     // console.log(`Server[port:${opt.port}]: handler`, data)
 * })
 *
 * setTimeout(() => {
 *     console.log('ms', ms)
 *     // console.log('ms', JSON.stringify(ms))
 *     wo.stop()
 * }, 3000)
 *
 */
function WConverhpServer(opt = {}) {

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

    //maxPort, TCP port之值域上限
    let maxPort = 65535

    //optSafe, 數值選項一律以wsemi之安全整數模式檢核: 其預設(寬鬆)對Infinity與超出安全整數者皆回true,
    //而Infinity給hapi之port會於建構即拋錯、給maxBytes(sizeSlice/sizeMsg)會使伺服器啟動失敗、給setTimeout(delayForSlice)會溢位成1ms;
    //超出安全整數者(如Number.MAX_SAFE_INTEGER+1)則使hapi以「must be a safe number」於建構同步拋錯. 各選項另依其sink之值域上限檢核
    let optSafe = { useLimitSafe: true }

    //port
    let port = get(opt, 'port')
    if (!ispint(port, optSafe) || cint(port) > maxPort) {
        port = 8080
    }
    port = cint(port) //檢核與正規化須成對: ispint亦接受數字字串

    //useInert
    let useInert = get(opt, 'useInert')
    if (!isbol(useInert)) {
        useInert = true
    }

    //pathStaticFiles
    let pathStaticFiles = get(opt, 'pathStaticFiles')
    if (!isestr(pathStaticFiles)) {
        pathStaticFiles = 'dist'
    }

    //pathUploadTemp
    let pathUploadTemp = get(opt, 'pathUploadTemp')
    if (!isestr(pathUploadTemp)) {
        pathUploadTemp = './uploadTemp'
    }
    //errCreateTemp, 暫存資料夾建立失敗之原因; 於 evEmitDelay 定義後以建構期事件通知
    //why: wsemi 之 fsCreateFolder 以回傳 { error } 表達失敗而不拋, 原本丟棄回傳值 —— 建構期 0 則事件, 之後每一次上傳才各自失敗
    //(該路徑為既有檔案、無權限等; 實測第十輪 D7)。與埠被占同為啟動失敗, 依 JSDoc 以 error 事件通知而不拋出
    let errCreateTemp = ''
    if (!fsIsFolder(pathUploadTemp)) {
        let rc = fsCreateFolder(pathUploadTemp)
        if (haskey(rc, 'error')) {
            errCreateTemp = getErrorMessage(rc.error)
        }
    }

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

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

    //sizeSlice, 交予hapi之payload.maxBytes, 其要求為safe number
    //檢核與正規化須成對: ispint亦接受數字字串('1048576'), 而本值會原樣回傳給client並由其以!==比對(見client之sendDataSlice),
    //未正規化時兩端縱使組態為同一個值, 字串與數值仍判為mismatch而使上傳整個失敗
    let sizeSlice = get(opt, 'sizeSlice')
    if (!ispint(sizeSlice, optSafe)) {
        sizeSlice = 1024 * 1024 //1m
    }
    sizeSlice = cint(sizeSlice)

    //sizeMsg, 單次請求本體上限, 適用於除切片(/slc)外之各API(/main、/ulctr、/dwgfn、/dw)
    //why: 此類請求須整包讀入記憶體再反序列化(/main實測記憶體約為本體5至6倍), 上限若給到遠超RAM之值(原為1tb), 單一請求即可令整個行程OOM;
    //切片(/slc)為串流直接落地不緩衝, 其單次上限為sizeSlice; 大檔本就應走切片上傳, 總大小不受此限制
    let sizeMsg = get(opt, 'sizeMsg')
    if (!ispint(sizeMsg, optSafe)) {
        sizeMsg = 100 * 1024 * 1024 //100m
    }
    sizeMsg = cint(sizeMsg) //檢核與正規化須成對: ispint亦接受數字字串

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

    //corsOrigins
    let corsOrigins = get(opt, 'corsOrigins', [])
    if (!isearr(corsOrigins)) {
        corsOrigins = ['*']
    }

    //delayForSlice, 交予setTimeout故另受計時器上限約束(見maxTimer)
    let delayForSlice = get(opt, 'delayForSlice', '')
    if (!isp0int(delayForSlice, optSafe) || cint(delayForSlice) > maxTimer) {
        delayForSlice = 100
    }
    delayForSlice = cint(delayForSlice)

    //server
    let server = null
    if (get(opt, 'serverHapi')) {

        //use serverHapi
        server = opt.serverHapi

    }
    else {

        //create server
        server = Hapi.server({
            //host: 'localhost',
            port,
            routes: {
                timeout: {
                    server: false, //關閉伺服器超時
                    socket: false, //關閉socket超時
                },
                cors: {
                    origin: corsOrigins, //Access-Control-Allow-Origin
                    credentials: false, //Access-Control-Allow-Credentials
                    //additionalExposedHeaders, 本套件回應協定除本體外另靠此四個標頭傳成敗(Return-Type/Return-Msg)、可否重試(Return-Retryable)與檔名(Content-Disposition);
                    //瀏覽器對跨來源回應只讓 JS 讀 Access-Control-Expose-Headers 列出者(hapi 預設僅 WWW-Authenticate,Server-Authorization), 未列則 client 讀到空字串,
                    //download 路徑(只讀標頭不解析本體)整條失效: 錯誤封包被當檔案、檔名解析拋錯; execute/upload 只解析本體故不受影響. 用 additional 以保留 hapi 預設兩項
                    additionalExposedHeaders: ['Return-Type', 'Return-Msg', 'Return-Retryable', 'Content-Disposition'],
                },
                // compression: { //壓縮須納入通用訊息處理(obj2u8arr與u8arr2obj), 其他傳輸或提供下載檔案等, 通常為已壓縮, 故不須指定壓縮
                //     minBytes: {
                //         value: 1024, //超過1KB才壓縮
                //     },
                //     mime: {
                //         'application/json': true,
                //         'text/html': true,
                //         'application/octet-stream': true, //針對application/octet-stream壓縮的話, Content-Length得要回傳壓縮後的資料長度
                //     },
                // },
            },
        })

    }

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

    //optEv, 交予wsemi之evEmit/evEmitDelay之共用設定
    //why 派發交由wsemi: 其evEmit即為「於呼叫端堆疊上直接ev.emit並以try攔截」之單一擁有者, 且把本套件所需之紀律
    //(settle排在通報之前、通報自身亦包try並留痕、脫勾後仍於新堆疊內攔截、ms夾至計時器上限)全部寫成明文契約;
    //自行手寫等於把同一條規則寫第二遍, 而規則寫兩遍正是本帳本各條重複出現之形狀
    let optEv = {
        tag: 'w-converhp',
    }

    //evEmit, 於呼叫端之堆疊上派發
    //funSettle, settle該次請求之pm使流程不懸置; 本套件之pm為execute/upload/download事件之最後一個參數,
    //  wsemi不猜測pm之位置(其JSDoc明載), 故由此處指定
    //funEmit, wsemi之通報形狀為{fun,name,msg,args}物件, 而本套件error事件之對外契約為**字串訊息**, 於此轉換為本套件形狀;
    //  該轉換須經funEmit而非另發一則, 否則同一次失敗會有兩則事件(違反帳本R5)
    let evEmit = (name, ...args) => {
        return evEmitBase(ev, name, args, {
            ...optEv,
            funSettle: () => {
                let pm = args[args.length - 1]
                if (ispm(pm)) {
                    pm.reject(`listener of event[${name}] error`)
                }
            },
            funEmit: funEmitOfPkg,
        })
    }

    //evEmitDelay, 以timer脫勾後派發(脫勾後仍由wsemi於新堆疊內以try攔截); **僅供建構期之失敗事件使用**
    //why: startServer()於建構期執行, 其失敗須以error事件通知, 而應用端於`new`回傳後才有機會呼叫wsv.on('error', ...)
    //實測(tmp/probe_r8_defer.mjs): 建構後同步註冊者直接派發亦收得到(該.catch本就在promise鏈上), 但隔一個await才註冊者收不到;
    //其餘32個派發站點皆於請求進來後才觸發, 脫勾對其毫無作用故一律不脫勾
    let evEmitDelay = (name, ...args) => {
        return evEmitDelayBase(ev, name, args, {
            ...optEv,
            funEmit: funEmitOfPkg,
        })
    }

    //funEmitOfPkg, 把wsemi之物件型通報轉為本套件之字串型error事件; 其餘一律原樣派發
    //取因一律經getErrorMessage: err為應用端throw之任意值, 其message可為拋錯之getter、toString可拋錯(見帳本R10)
    function funEmitOfPkg(nm, ...a) {
        if (nm === 'error' && iseobj(a[0]) && a[0].fun === 'listener') {
            let nmL = get(a[0], 'name', '')
            let errL = get(a[0], 'msg')
            console.log(`listener of event[${nmL}] error`, errL)
            return ev.emit('error', `listener of event[${nmL}] error: ${getErrorMessage(errL)}`)
        }
        return ev.emit(nm, ...a)
    }

    //建構期失敗: 暫存資料夾無法建立(見 errCreateTemp); 須於 evEmitDelay 定義之後
    if (isestr(errCreateTemp)) {
        evEmitDelay('error', `create pathUploadTemp[${pathUploadTemp}] error: ${errCreateTemp}`)
    }

    //checkConn, 各路由呼叫verifyConn之唯一出口, 拋錯或reject一律視為未通過, 各路由不得再自行呼叫verifyConn
    //why: 原本僅apiMain有try/catch, 其餘五路由於verifyConn拋錯或reject時會回HTTP 500且body不可解析,
    //六路由對同一種失敗之行為不對稱; 收斂於此後一律回permission denied, 並以error事件通知應用端(與其他路由之錯誤回報方式一致)
    async function checkConn(inp) {
        let m = false
        try {
            m = verifyConn(inp)
            if (ispm(m)) {
                m = await m
            }
        }
        catch (err) {

            //m先歸false再回報: 以往回報寫在前面, 而取因以get(err,'message',err)配樣板字面量組訊息 ——
            //應用端verifyConn拋出message為拋錯getter之值時, 該行於catch內再拋, 例外逸出checkConn而由各路由上拋至hapi, 回裸HTTP 500且0則事件;
            //而本函數存在之理由正是「六路由對同一種失敗一律回permission denied」. 先歸false使該保證在結構上成立
            m = false

            //取因一律經getErrorMessage(見帳本R10)
            console.log(`verifyConn error for apiType[${get(inp, 'apiType', '')}]`, err) //使用err.message會過於簡化, 另外要開啟顯示err供debug
            evEmit('error', `verifyConn error for apiType[${get(inp, 'apiType', '')}]: ${getErrorMessage(err)}`)
        }
        return m === true
    }

    //procApp, 呼叫應用端事件之唯一出口
    //why 三者合一: procDeal/procUpload/procDownload 原為同一函數之三份複本(procUpload與procDownload逐字相同, 只差事件名),
    //  故任何加諸於「呼叫應用端」之紀律都得寫三遍 —— 而規則寫多遍正是本帳本各條重複出現之形狀
    //funThen, 僅execute需要對回傳值再加工(補output鍵、刪input), 以參數表達其差異而非另開一份複本
    //無人接聽時由callApp拒絕pmm並回報, 使請求不懸置(見src/callApp.mjs與帳本R12)
    let procApp = (name, args, funThen) => {

        //pm, pmm
        let pm = genPm()
        let pmm = genPm()

        //重新處理回傳結果, 須早於派發以確保拒絕必有處理者
        pmm
            .then((output) => {

                //canon, 應用端結果之正規化只在此處(三事件、各交付路徑共用)
                //undefined → null: 序列化會把值為 undefined 之鍵整個省略, 協定鍵之內之 output/msg 因而消失 ——
                //原本 execute(procDeal)與合併消費(managerMergeSlices 之 consume)各自正規化, 去重路徑(check-total-hash)沒有,
                //同一個「應用端 pm.resolve() 不帶值」使 upload() 依伺服器走哪條路徑而回 null 或 undefined(實測第十輪 D5)
                if (output === undefined) {
                    output = null
                }

                //function/symbol: JSON 對其靜默丟鍵而非拋錯, 故 encodeOut 之嚴格模式抓不到 —— 原本 execute 得 invalid msg.output 且兩端 0 則事件,
                //合併消費之 .ro 缺鍵而同一 queueId 重送再呼叫應用端(實測第十輪 A8); JSDoc 明載「不能序列化者回錯誤封包並發error事件」, 於此兌現
                //以 typeof 判定而不用 wsemi 之 isfun: 後者經 Object.prototype.toString.call, 會觸發應用端值之 Symbol.toStringTag getter
                let tp = typeof output
                if (tp === 'function' || tp === 'symbol') {
                    pm.reject('output can not be serialized') //settle 排在回報之前(帳本 R10 之結構層)
                    evEmit('error', `event[${name}] output can not be serialized: output is a ${tp}`)
                    return
                }

                pm.resolve(isfun(funThen) ? funThen(output) : output)
            })
            .catch((err) => {
                pm.reject(err)
            })

        //callApp, 派發並保證必定終結
        callApp(ev, evEmit, name, args, pmm, (msg) => {
            evEmit('error', msg)
        })

        return pm
    }

    //procDeal
    async function procDeal(data) {
        return procApp('execute', [get(data, 'func', ''), get(data, 'input', null)], (output) => {

            //add output, undefined 已由 procApp 正規化為 null(前端收不到 output 鍵即判為畸形封包而拒絕, 見 procApp 之 canon)
            data['output'] = output

            //delete input, 因input可能很大故回傳數據不包含原input
            delete data['input']

            return data
        })
    }

    //procUpload
    async function procUpload(input) {
        return procApp('upload', [input])
    }

    //procDownload
    async function procDownload(input) {
        return procApp('download', [input])
    }

    //路由層之共同部件(第十一輪 G6): 六路由之差異寫在 routeSpec 一張表上(apiType、handler 事件之 api、authorization 來源、錯誤狀態碼、下載欄位順序),
    //下列函數一律查表而不各自傳參; 原本同一規則手寫展開於六條路由(前置 6 份、token 切割 2 份 + 合成 1 份、下載欄位處置 3 份、/dwgf 狀態碼 8 處),
    //每加一條規則就得寫六遍而漏其一(第十輪 A5/A6/A9/F10 各改 3–6 處; 第十一輪 N2/N4 皆為「同一規則第二站點沒套」)

    //pfxToken, 授權方案前綴(含尾隨空白), 供自 authorization 切出 token
    let pfxToken = `${cstr(tokenType)} `

    //ctxOf, 各路由之請求脈絡(headers / query / authorization / token)之唯一取得處, 依 spec.authFrom 決定 authorization 與 token 之來源
    //  header: authorization 為請求標頭原樣; token 須先確認授權方案前綴相符才切, 不符者視為未帶 token(帳本 R15)
    //    why: 原以 slice(tokenType.length + 1) 無條件切, 從不驗前綴 —— 實測(tmp/probe_r9_final.mjs 之 Q4, server 設 tokenType='Token'):
    //    送 `Bearer abc123` 切出 " abc123"(帶前導空白)、送 `Basic dXNlcjpwYXNz` 整段成為 token, 兩者皆為可通過 isestr 之錯誤授權值, 應用端於 download 事件收到後無從察覺
    //  query: /dwgf 之下載由瀏覽器導覽而無標頭可用, token 走 query string, authorization 由套件合成為 `<tokenType> <token>`(刻意, 使應用端 verifyConn 於六路由所見同一形狀; B 卷 B4)
    let ctxOf = (req, spec) => {
        let headers = get(req, 'headers')
        headers = iseobj(headers) ? headers : ''
        let query = get(req, 'query')
        query = iseobj(query) ? query : ''
        let authorization = ''
        let token = ''
        if (spec.authFrom === 'query') {
            token = get(query, 'token', '')
            token = isestr(token) ? token : ''
            if (isestr(token)) {
                authorization = `${tokenType} ${token}`
            }
        }
        else {
            authorization = get(headers, 'authorization', '')
            authorization = isestr(authorization) ? authorization : ''
            token = authorization.startsWith(pfxToken) ? authorization.slice(pfxToken.length) : ''
        }
        return { headers, query, authorization, token }
    }

    //replyOf, 各路由之錯誤回覆器: 種類(kind)→ HTTP 狀態碼由 spec.statusOf 查表, 本體封包與 Return-Type / Return-Msg 標頭一律經 responseU8aStreamWithError; param 另標示 retryable:false
    //why 狀態碼與 retryable 為兩張表: 狀態碼依路由之消費者而異(只有 /dwgf 非 2xx, 帳本 R6 之例外), retryable 依錯誤種類而異(只有 param 可證明不需重試, 見專案重試原則);
    //原本 /dwgf 之 8 處 replyError(code, ...) 各自手寫狀態碼, 其餘五路由 9 處 retryable:false 各自手寫 —— 實為「同一 kind」之兩個面向(B 卷 §③-3.4.2③)
    let replyOf = (res, spec) => {
        let send = (kind, msg, opt) => {
            return responseU8aStreamWithError(res, msg, opt).code(spec.statusOf[kind])
        }
        return {
            permission: () => send('permission', 'permission denied'),
            param: (msg) => send('param', msg, { retryable: false }),
            app: (msg) => send('app', msg),
            output: (msg) => send('output', msg),
            packet: (msg) => send('packet', msg),
            internal: (msg) => send('internal', msg),
        }
    }

    //admit, 各路由之共同前置: 經 checkConn 呼叫 verifyConn, 未通過即回 permission denied(**不發 handler 事件**); 通過才發 handler 事件。回傳 null 代表放行, 各路由之參數檢核一律在其後
    //why 函數版而非 hapi route 級之 options.pre / options.ext(A 卷 §③-3.6(a) 允許但要求寫下理由): 兩者對本套件之保證等價(皆為 route 級, 不污染外部 serverHapi 之其他路由);
    //而 handler 內一行 `if (denied) return denied` 使「先驗權限 → 發 handler 事件 → 檢核參數」之順序於每條路由可直接讀出, 不需追 hapi 生命週期;
    //且 pre 之 takeover 回應與串流型 payload(/main、/slc 之 output:'stream')之互動未經驗證, 不值得為此承擔。不用 server.ext: 那是伺服器層, 會套到外部 serverHapi 之其他路由
    let admit = async(req, ctx, spec, reply) => {
        let m = await checkConn({ apiType: spec.apiType, authorization: ctx.authorization, query: ctx.query, headers: ctx.headers, req })
        if (m !== true) {
            return reply.permission()
        }
        evEmit('handler', {
            api: spec.api,
            headers: ctx.headers,
            query: ctx.query,
        })
        return null
    }

    //readOutput, 三條下載路由對應用端 download 事件回傳值之逐欄處置: 依 spec.fields 之**順序**讀取(attempt, 見帳本 R1)並判定(validDownloadField), 任一欄失敗即銷毀已取得之串流、發一則帶真因之事件、回錯誤封包
    //回 { ok: true, v } 或 { ok: false, res }(res 為已組好之錯誤回覆); 應用端拒絕之分流不在此(各路由於呼叫前處理)
    //why 只抽「逐欄之原子」而不抽整段(B 卷 §③-3.4.2④): 三路由之欄位集合、順序、選用性兩兩不同, 順序是契約(api-characterization 鎖住), 由 spec.fields 表達而非函數參數
    //fileSize 之值須經 getErrorMessage 而不可直接進樣板(帳本 R10): 其為應用端交出之任意值, 樣板求值時 toString 可拋錯
    let readOutput = (r, fileId, spec, reply) => {
        let rf = readDownloadFields(r, spec.fields.map((f) => f.name))
        if (!rf.ok) {
            destroyStreamRead(get(rf.fields, 'streamRead')) //streamRead 排在首位, 故後續欄位拋錯時該串流已在套件手上, 不清理即 fd 持續開啟(實測 tmp/probe_r9_rest.mjs 第1節)
            evEmit('error', `download fileId[${fileId}] output error: can not read field[${rf.field}]: ${rf.cause}`)
            return { ok: false, res: reply.output('invalid streamRead') }
        }
        let v = { streamRead: rf.fields.streamRead }
        for (let f of spec.fields) {
            if (f.name === 'streamRead') {
                continue
            }
            let vv = validDownloadField(f.name, rf.fields[f.name])
            if (vv.ok) {
                v[f.name] = vv.value
                continue
            }
            if (f.optional === true && !isestr(vv.cause)) {
                v[f.name] = '' //選用欄位: 判定不通過且判定本身未拋錯者視為未給(原本 toString 拋錯者送出空檔名之標頭, 第十輪 A6); 判定時拋錯者與必要欄位同一處置
                continue
            }
            destroyStreamRead(v.streamRead) //提供 stream 前發生錯誤, 得強制 destroy
            let detail = (f.name === 'fileSize') ? `[${getErrorMessage(rf.fields.fileSize)}]` : ''
            evEmit('error', `download fileId[${fileId}] output error: invalid ${f.name}${detail}${isestr(vv.cause) ? `: ${vv.cause}` : ''}`)
            return { ok: false, res: reply.output(`invalid ${f.name}`) }
        }
        return { ok: true, v }
    }

    //replyPacket, 控制封包路由之共同收尾: 以 success / error 鍵包裝結果, 經 encodeOut 嚴格序列化(不能者回錯誤封包 + 一則事件, 不可宣稱成功), 以 responseU8aStream 回應
    //label 為事件訊息之主詞(如 execute func[...]), 由呼叫端組; 含請求端值者須先經 getErrorMessage(帳本 R10)
    let replyPacket = async(res, reply, pm, label) => {
        let out = {}
        let returnType = ''
        let returnMsg = 'need to parse'
        await pm
            .then((r) => {
                out.success = r
                returnType = 'success'
            })
            .catch((err) => {
                out.error = err
                returnType = 'error'
            })
        let u8aOut = encodeOut(out, (msg) => {
            evEmit('error', `${label} output can not be serialized: ${msg}`)
        })
        if (u8aOut === null) {
            return reply.internal('output can not be serialized')
        }
        return responseU8aStream(res, u8aOut, { returnType, returnMsg })
    }

    //failPayload, parse:true 之路由(/ulctr、/dwgfn、/dw)本體解析失敗之處置: 由套件擁有, 回本套件之錯誤封包 + 一則事件, 而非 hapi 之裸 400
    //why: JSDoc 承諾「錯誤回應一律為 HTTP 200 + 錯誤封包(唯 /dwgf 例外)」, 而 hapi 對 JSON 語法錯誤(截斷、中間層改寫)於路由前置階段回 400 且 handler 從未執行 ——
    //前端收到無法解析之本體、兩端 0 則事件、client 照常重試(第十一輪 A1, A 卷實測 tmp/r11A_3_out.txt §D/§E)。
    ///main 為 parse:false 自行解碼, 早於第四輪即回 invalid request packet + 一則事件(#26); 三條 parse:true 路由為同一規則之未套站點(帳本 R6)
    //413(本體超過 maxBytes)原樣拋回: client 以狀態碼判定其為可證明不需重試, 該契約不變。解析失敗屬傳輸不穩, 依重試原則不標示 retryable
    let failPayload = (spec) => {
        return (req, h, err) => {
            if (get(err, 'output.statusCode') === 413) {
                throw err
            }
            evEmit('error', `invalid request packet for ${spec.api}: ${getErrorMessage(err)}`)
            return replyOf(h, spec).packet('invalid request packet').takeover()
        }
    }


    //apiMain
    let apiMain = {
        path: `/${apiName}/main`,
        method: 'POST',
        options: {
            payload: {
                maxBytes: sizeMsg, //hapi預設1mb; 本路由整包緩衝, 上限須誠實反映記憶體能力, 由opt.sizeMsg設定(預設100mb)
                maxParts: 1000 * 1000 * 1000, //預設為1000, 給予3次方
                timeout: false, //避免請求未完成時中斷
                output: 'stream', //代表前端用stream傳至伺服器(Content-Type為application/octet-stream)
                parse: false,
            },
            timeout: {
                server: false, //關閉伺服器超時
                socket: false, //關閉socket超時
            },
        },
        handler: async function (req, res) {
            // console.log(req, res)
            // console.log('payload', req.payload)

            //spec, reply, ctx, admit: 路由前置(查表取脈絡與錯誤回覆器, 驗權限, 發 handler 事件), 見 routeSpec / ctxOf / replyOf / admit
            let spec = routeSpec.main
            let reply = replyOf(res, spec)
            let ctx = ctxOf(req, spec)
            let denied = await admit(req, ctx, spec, reply)
            if (denied) {
                return denied
            }

            //receive
            let receive = () => {

                //pm
                let pm = genPm()

                //chunks
                let chunks = []

                //nReceived, 自行累計已收位元組
                //why: hapi之maxBytes對output:'stream'僅於請求帶Content-Length時預判(實測), 無Content-Length之chunked本體會整包收進來,
                //上限形同虛設; 故於此計數, 超限即停止接收、釋放已緩衝資料, 由handler比照hapi回413
                let nReceived = 0
                let bOver = false

                let smw = new stream.Writable({
                    write(chunk, encoding, cb) {
                        // console.log('smw receive payload', chunk)

                        //check, 超限後不再緩衝, 但持續讀完剩餘本體(丟棄)而不destroy請求串流: destroy會使hapi視為請求中止而直接斷線, 413回不到前端;
                        //讀完於end才回413, 記憶體仍受保護(不再累積), 且前端必收到明確回應
                        nReceived += chunk.length
                        if (nReceived > sizeMsg) {
                            chunks = []
                            bOver = true
                            cb()
                            return
                        }

                        //push
                        chunks.push(chunk)
                        // console.log('chunk.length', chunk.length)

                        //cb
                        cb()

                    }
                })

                //finish
                smw.on('finish', () => {
                    // console.log(`smw finish`)
                })

                //pipe
                req.payload.pipe(smw)

                //end
                req.payload.on('end', () => {
                    // console.log(`req.payload end`)

                    //check, 超限者於此reject, 由handler回413
                    if (bOver) {
                        pm.reject('payload too large')
                        return
                    }

                    //bb
                    let bb = Buffer.concat(chunks)
                    // console.log('bb', bb, bb.length)

                    //clear, 釋放記憶體
                    chunks = []

                    //resolve
                    pm.resolve(bb)

                })

                //close
                req.payload.on('close', () => {
                    // console.log(`req.payload close`)
                })

                //error
                req.payload.on('error', (err) => {
                    console.log(`apiMain req.payload err`, err) //使用err.message會過於簡化, 另外要開啟顯示err供debug
                    evEmit('error', `receive payload error: ${getErrorMessage(err)}`)
                    pm.reject(`receive payload error: ${getErrorMessage(err)}`)
                })

                return pm
            }

            //receive
            let bbInp = null
            try {
                bbInp = await receive()
            }
            catch (err) {

                //check, 超過sizeMsg(無Content-Length之本體由receive自行計數攔截), 比照hapi對有Content-Length者之處置回413,
                //使前端不論送法皆收到同一種結果(Payload Too Large), 且不觸發execute事件
                if (err === 'payload too large') {
                    return res.response({ statusCode: 413, error: 'Request Entity Too Large', message: `Payload content length greater than maximum allowed: ${sizeMsg}` }).code(413)
                }

                //其餘(如接收中斷線)維持原行為向外拋出
                throw err
            }
            // console.log('bbInp', bbInp)

            //u8aInp
            let u8aInp = new Uint8Array(bbInp)
            // console.log('u8aInp', u8aInp)

            //u8arr2obj, 以嚴格模式取狀態並檢核請求封包之形狀
            //why: 寬鬆模式對壞封包回{}而不報錯, procDeal隨即以func為空字串、input為null觸發應用端execute事件,
            //之後把output寫進該空物件並回200+Return-Type success —— 畸形或截斷之請求本體被「回報為成功」, 且應用端被一次不存在的呼叫驚動。
            //改為解不出或非有效物件即回錯誤封包並**不觸發任何應用端事件**; 屬傳輸不穩(截斷、中間層改寫), 依重試原則不標示retryable
            let rdInp = u8arr2obj(u8aInp, { returnWithStateAndMsg: true })
            if (get(rdInp, 'state') !== 'success' || !iseobj(rdInp.msg)) {
                //訊息之值經 getErrorMessage: 解碼成功但非物件者(如頂層陣列)其值來自請求端, 樣板對之求值時元素之 toString 可為非函數值而拋 ——
                //[{"toString":1}] 即回裸 HTTP 500 + 0 則事件(實測第十輪 A1, 帳本 R10)
                let msgInp = getErrorMessage(get(rdInp, 'msg', ''))
                evEmit('error', `invalid request packet for apiMain: ${isestr(msgInp) ? msgInp : 'not an effective object'}`)
                return reply.packet('invalid request packet')
            }
            let inp = rdInp.msg
            // console.log('inp', inp)

            //procDeal, 收尾經 replyPacket(應用端execute事件之回傳值或拒絕值無法序列化時不可宣稱成功, 見encodeOut); func 為請求端任意值, 須經 getErrorMessage(帳本 R10)
            return replyPacket(res, reply, procDeal(inp), `execute func[${getErrorMessage(get(inp, 'func', ''))}]`)
        },
    }

    //apiUploadCheck
    let apiUploadCheck = {
        path: `/${apiName}/ulctr`,
        method: 'POST',
        options: {
            payload: {
                maxBytes: sizeMsg, //控制用JSON(含check-slices-hash之各切片雜湊清單, 每片約36byte), parse:true整包讀入記憶體, 與/main共用上限(預設100mb, 約對應2.7tb檔案), 原為1tb形同無上限
                maxParts: 1000 * 1000 * 1000, //預設為1000, 給予3次方
                timeout: false, //避免請求未完成時中斷
                // output: 'stream',
                parse: true, //前端送obj過來須自動解析
                failAction: failPayload(routeSpec.ulctr), //解析失敗回套件錯誤封包(見 failPayload)
            },
            timeout: {
                server: false, //關閉伺服器超時
                socket: false, //關閉socket超時
            },
        },
        handler: async function (req, res) {
            // console.log(req, res)
            // console.log('payload', req.payload)

            //spec, reply, ctx, admit: 路由前置, 見 routeSpec / ctxOf / replyOf / admit
            let spec = routeSpec.ulctr
            let reply = replyOf(res, spec)
            let ctx = ctxOf(req, spec)
            let denied = await admit(req, ctx, spec, reply)
            if (denied) {
                return denied
            }

            //mode, 從payload接收
            let mode = get(req, 'payload.mode', '')

            //check
            if (mode !== 'check-total-hash' && mode !== 'check-slices-hash' && mode !== 'merge-slices-push' && mode !== 'merge-slices-get') {
                // console.log('invalid mode in payload')
                //mode 為請求端任意 JSON 值, 於檢核前進樣板須經 getErrorMessage: {"toString":1} 之自有屬性 toString 非函數, 樣板求值即拋 → 裸 HTTP 500 + 0 則事件(實測第十輪 D3)
                return reply.param(`invalid mode[${getErrorMessage(mode)}] in payload`)
            }

            //fileHash, 從payload接收
            let fileHash = get(req, 'payload.fileHash', '')
            // console.log(mode, 'fileHash', fileHash)

            //check, fileHash會參與pathUploadTemp下之路徑組裝, 須為安全識別字(英數字), 否則可 ../ 逸出資料夾
            if (!isSafeId(fileHash)) {
                // console.log('invalid fileHash in payload')
                return reply.param('invalid fileHash in payload')
            }

            //chunkTotal, 從payload接收, 僅merge-slices-push使用
            //check, chunkTotal決定mergeSlices配置路徑陣列之長度, 須為正整數; 巨大值另由mergeSlices逐片確認存在(缺片即停)兜底, 不會依此值無上限配置而耗盡記憶體
            let chunkTotal = get(req, 'payload.chunkTotal', '')
            if (mode === 'merge-slices-push') {
                if (!ispint(chunkTotal)) {
                    // console.log('invalid chunkTotal in payload')
                    return reply.param('invalid chunkTotal in payload')
                }
                chunkTotal = cint(chunkTotal)
            }

            //filename, 從payload接收, **原樣**交予應用端(check-total-hash 之去重與 merge-slices-get 之消費皆同), 於此讀一次而非各模式各讀一份
            //filenameSafe, 其淨化值(sanitizeFilename: 只取最末路徑段、去除各平台非法字元與保留裝置名; 未給或全為非法字元者為空字串, 套件不代為發明 unknown), 供應用端組落地路徑
            //why 兩者皆給而非就地淨化(第十一輪 N4, 兩份複審一致): 本套件自身不以此值組任何路徑(切片與合併檔皆以經 isSafeId 之 fileHash/packageId 命名), 風險只在應用端如何使用;
            //就地淨化會刪掉合法資料 —— a:b.txt 於 Linux 合法、瀏覽器 webkitdirectory 之 docs/2024/report.pdf 會只剩 report.pdf 且應用端無從得知目錄曾存在。
            //對標 multer: originalname 原樣且明載不可信, 另給 filename。本套件對另一方向(伺服器交出之檔名, client 之 downloadStream)則就地淨化 ——
            //因為那個值是套件自己要拿去 path.resolve 落地的; 誰要用它, 誰淨化(帳本 R19)
            let filename = get(req, 'payload.filename', '')
            let filenameSafe = sanitizeFilename(filename, '')

            //throwIfWorkerError, checkTotalHash與checkSlicesHash以回傳 { error } 表達失敗而非拋錯, 未檢核即會被當成功回給前端
            //why: 原無此檢核 —— checkTotalHash 對非法 fileSize 回 { error: 'invalid fileSize in payload' },
            //該物件原樣進入 out.success 而以 Return-Type: success 回應; 前端 sendDataSlice 之各項檢核(bAllHash/sizeSlice/bSls)皆為 undefined 而略過,
            //於切片迴圈首次使用 resUpCkt.slks 時拋 TypeError: Cannot read properties of undefined (reading 'indexOf')(實測 tmp/probe_r9_rest.mjs 第2節)
            //亦即「伺服器宣稱成功、前端崩在一個看不出原因的地方」, 屬帳本 R6(不得宣稱成功)與 R3(不得把底層沒拋錯當成功)之同型
            //回非空字串即代表失敗, 由呼叫處以 Promise.reject 交出而成為錯誤封包(值維持純字串, 與其他錯誤封包一致);
            //不標示 retryable(標示會減少重試, 須另行舉證, 見專案重試原則)
            let workerError = (o) => {
                if (iseobj(o) && haskey(o, 'error')) {
                    return `${mode}: ${cstr(o.error)}`
                }
                return ''
            }

            //internal, 本路由內部呼叫(worker 與合併佇列)之唯一出口: 其例外為套件內部失敗, 細節只進 error 事件, 回前端之訊息不含細節(帳本 R17)
            //why: 原本例外原樣進入 procCore 之 catch 而成為錯誤封包之內容 —— 暫存資料夾不在時前端收到 `Error: fd[<伺服器絕對路徑>] is not a folder` 且伺服器 0 則事件
            //(實測第十輪 A11/N5); 同套件之 /slc 寫入失敗、S5、check-total-hash 之 path 皆已明載不外送伺服器路徑, 唯此處不守
            //應用端之拒絕值不經本函數(merge-slices-get 之 funConsume 依設計原樣傳回前端)
            let internal = async(stage, fn) => {
                try {
                    return await fn()
                }
                catch (err) {
                    evEmit('error', `upload-controller mode[${mode}] ${stage} error: ${getErrorMessage(err)}`)
                    return Promise.reject(`${mode} failed`)
                }
            }

            //procCore
            let procCore = async() => {
                let out = null
                if (mode === 'check-total-hash') {

                    //fileSize, 從payload接收
                    let fileSize = get(req, 'payload.fileSize', '')
                    // console.log(mode, 'fileSize', fileSize)

                    //checkTotalHash
                    out = await internal('check total hash', () => checkTotalHash(fileSize, sizeSlice, fileHash, pathUploadTemp))
                    // console.log(mode, 'out', out)

                    //check, 失敗不得被當成功回給前端(見workerError)
                    let we = workerError(out)
                    if (isestr(we)) {
                        return Promise.reject(we)
                    }

                    //check, 因合併大檔後可能非預期中斷而重傳, 每次偵測有合併完成大檔, 就得調用procUpload讓伺服器攔截函數處理
                    if (out.bAllHash) {

                        //ri
                        let ri = {
                            from: 'check-total-hash',
                            filename,
                            filenameSafe,
                            path: out.path, //out.path使用path.resolve為絕對路徑
                        }

                        //procUpload
                        // console.log('procUpload start')
                        let ro = await procUpload(ri)
                        // console.log('procUpload done', ro)

                        //out merge, 此處(bAllHash=true時)ro儲存至out.msg, 非此處(bAllHash=false時)回傳原本out, 前端須分開處理
                        out = {
                            ...out,
                            msg: ro,
                        }

                    }

                    //path, 為伺服器絕對路徑, 僅供上方procUpload使用, 不回傳前端(前端未使用, 且會外洩伺服器目錄結構)
                    delete out.path

                    //sizeSlice, 回傳伺服器切片大小供前端比對, 前後端不一致時前端可提早以明確訊息終止, 而非於/slc被413拒絕或永遠無法續傳
                    out.sizeSlice = sizeSlice

                }
                else if (mode === 'check-slices-hash') {

                    //fileSliceHashs, 從payload接收
                    let fileSliceHashs = get(req, 'payload.fileSliceHashs', [])
                    // console.log(mode, 'fileSliceHashs', fileSliceHashs)

                    //checkSlicesHash
                    out = await internal('check slices hash', () => checkSlicesHash(fileSliceHashs, fileHash, pathUploadTemp))
                    // console.log(mode, 'out', out)

                    //check, 失敗不得被當成功回給前端(見workerError); 本模式之失敗為 invalid fileHash 與 no fileSliceHashs
                    let we = workerError(out)
                    if (isestr(we)) {
                        return Promise.reject(we)
                    }

                }
                else if (mode === 'merge-slices-push') {

                    //mmg.push, chunkTotal已於上方檢核為正整數; 合併檔在而.done不在(前次中止)時會先驗證完整性, 故為async
                    let queueId = await internal('push merge task', () => mmg.push(fileHash, chunkTotal, pathUploadTemp))

                    //out
                    out = {
                        queueId,
                    }

                }
                else if (mode === 'merge-slices-get') {

                    //queueId, 從payload接收
                    let queueId = get(req, 'payload.queueId', '')
                    // console.log(mode, 'queueId', queueId)

                    //mmg.get, 狀態判定與消費(呼叫應用端upload事件)皆於managerMergeSlices內依其檔頭狀態表處理, 此處只提供消費函數與記錄函數並組回應;
                    //首次消費之結果會落地, 同一queueId之重送(回應遺失、逾時)直接回儲存之結果而不再呼叫應用端; 應用端拒絕值由mmg.get原樣向外拋, 經procCore之catch成為error封包送回前端(依重試原則由前端重送)
                    let r = await mmg.get(queueId, pathUploadTemp, {
                        funConsume: async(fp) => {
                            return await procUpload({
                                from: 'merge-slices-get',
                                filename,
                                filenameSafe,
                                path: fp, //fp使用path.resolve為絕對路徑
                            })
                        },
                        funLog: (msg) => {
                            evEmit('error', msg)
                        },
                    })

                    //out, r.path為伺服器絕對路徑, 不回傳前端; state為'success'時msg為應用端之結果(首次消費或儲存之結果), 'error'時為不含伺服器路徑與底層細節之訊息
                    out = {
                        state: r.state,
                        msg: r.msg,
                        queueId,
                        filename,
                    }

                    //check, 失敗細節(含伺服器路徑)僅以error事件通知應用端, 不回傳前端; 格式為「<msg> for fileHash[...]: <reason>」
                    if (r.state === 'error' && isestr(r.reason)) {
                        evEmit('error', `${r.msg} for fileHash[${fileHash}]: ${r.reason}`)
                    }

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

                return out
            }

            //procCore, 收尾經 replyPacket(應用端upload事件之回傳值(經merge-slices-get之msg)無法序列化時不可宣稱成功, 見encodeOut); mode 已檢核為四個字面之一
            return replyPacket(res, reply, procCore(), `upload-controller mode[${mode}]`)
        },
    }

    //apiUploadSlice
    let apiUploadSlice = {
        path: `/${apiName}/slc`,
        method: 'POST',
        options: {
            payload: {
                maxBytes: sizeSlice, //單一切片本就不超過sizeSlice(checkTotalHash亦以sizeSlice判定切片是否完整), 前後端sizeSlice須一致; 原為1tb形同無上限
                maxParts: 1000 * 1000 * 1000, //預設為1000, 給予3次方
                timeout: false, //避免請求未完成時中斷
                output: 'stream', //代表前端用stream傳至伺服器(Content-Type為application/octet-stream)
                parse: false,
            },
            timeout: {
                server: false, //關閉伺服器超時
                socket: false, //關閉socket超時
            },
        },
        handler: async function (req, res) {
            // console.log(req, res)
            // console.log('payload', req.payload)

            //spec, reply, ctx, admit: 路由前置, 見 routeSpec / ctxOf / replyOf / admit
            let spec = routeSpec.slc
            let reply = replyOf(res, spec)
            let ctx = ctxOf(req, spec)
            let denied = await admit(req, ctx, spec, reply)
            if (denied) {
                return denied
            }

            //chunkIndex, chunkTotal, packageId, 從headers接收
            let chunkIndex = get(ctx.headers, 'chunk-index', '')
            let chunkTotal = get(ctx.headers, 'chunk-total', '')
            let packageId = get(ctx.headers, 'package-id', '')

            //check
            if (!isp0int(chunkIndex)) {
                // console.log('invalid chunkIndex in headers')
                return reply.param('invalid chunkIndex in headers')
            }
            chunkIndex = cint(chunkIndex)
            if (!isp0int(chunkTotal)) {
                // console.log('invalid chunkTotal in headers')
                return reply.param('invalid chunkTotal in headers')
            }
            chunkTotal = cint(chunkTotal)
            if (!isSafeId(packageId)) { //packageId會參與切片檔路徑組裝, 須為安全識別字(英數字), 否則可 ../ 逸出資料夾
                // console.log('invalid packageId in headers')
                return reply.param('invalid packageId in headers')
            }

            //pathFileChunk
            let pathFileChunk = path.resolve(pathUploadTemp, `${packageId}_${chunkIndex}`)
            // console.log('pathFileChunk', pathFileChunk)

            //streamWrite
            let streamWrite = fs.createWriteStream(pathFileChunk)

            //receive
            let receive = () => {

                //pm
                let pm = genPm()

                //nReceived, 自行累計已收位元組
                //why: 同/main, hapi之maxBytes對output:'stream'僅於請求帶Content-Length時預判, 無Content-Length之chunked本體會繞過;
                //超限即停止落地(unpipe須早於pipe之data監聽, 故此監聽先註冊)並刪除已寫部分, 持續讀完剩餘本體而不destroy(否則413回不到前端), 於end回413
                let nReceived = 0
                let bOver = false

                //bWriteErr, 寫入失敗(資料夾被清、磁碟滿、權限不足)須攔截: Writable之error無監聽時會拋成未捕捉例外, 整個伺服器行程會崩潰而前端仍收到200
                //細節(含伺服器路徑)僅以error事件通知應用端, 回前端之訊息不含路徑
                let bWriteErr = false

                //bAbort, bEnded, 前端中斷(斷線)須收尾目的串流: .pipe()不會因源串流出錯或提前關閉而關閉目的, 寫入fd會隨每次中斷累積(實測5次中斷5個fd全開), 長跑伺服器終至EMFILE;
                //比照bOver/bWriteErr: destroy後於close刪除不完整切片(續傳本就以大小不等於sizeSlice判定須重傳, 殘留無用); bEnded供close判別「未end即close」之中斷路徑
                let bAbort = false
                let bEnded = false

                //done, 成功之唯一終結點, 於寫入串流 close 時判定: 須「request 本體已 end」且「寫入串流已 finish」(writableFinished: end() 正常走完、所有 write 之回呼皆已返回)
                //why: 原本於 request 之 end 即排程回 done, 而 pipe 於 end 時只是把最後一片**交給**寫入串流, 其 fs.write 尚未完成 ——
                //慢磁碟下(實測 tmp/probe_r11_a.mjs P1: 每次寫入延後 400ms)前端已收到 done 並送出 merge-slices-push, 合併讀到短切片而整個上傳以 merge slices failed 告終;
                //且 end 之後(flush 階段)才發生之寫入失敗, 錯誤晚於 delayForSlice 到達時 pm 已 resolve, 不完整之切片被當成功(A 卷 §①-1.3(a) 第 4 點)。
                //「done」須代表切片已交給 OS(fd 已關; 非持久化, 無 fsync)—— 與帳本 R18「完成標記須證明其所指之內容」同族
                //why 以 writableFinished 作肯定式證明而非列舉 bOver/bWriteErr/bAbort 之否定式(B 卷 §③-3.1): 被 destroy() 中斷者其 writableFinished 恆為假, 日後新增中斷路徑亦不需改此守衛;
                //bEnded 必早於 close: pipe 之 onend 與本路由之 end 監聽於同一次 emit 內同步執行, 而 finish/close 至少要到下一個 tick(兩份複審各自推導)
                let done = () => {
                    if (!bEnded || streamWrite.writableFinished !== true) {
                        return
                    }

                    //setTimeout, 切片上傳添加延遲處理, 避免佔滿伺服器CPU與流量
                    setTimeout(() => {
                        pm.resolve(`chunk[${chunkIndex + 1}/${chunkTotal}] of packageId[${packageId}] done`)
                    }, delayForSlice)
                }
                streamWrite.on('error', (err) => {
                    if (bWriteErr || bOver || bAbort) {
                        return
                    }
                    bWriteErr = true
                    req.payload.unpipe(streamWrite)
                    req.payload.resume() //持續讀完剩餘本體, 使錯誤回應能回到前端
                    console.log(`apiUploadSlice streamWrite chunk[${chunkIndex + 1}/${chunkTotal}] of packageId[${packageId}] err`, err)
                    evEmit('error', `write chunk[${chunkIndex + 1}/${chunkTotal}] of packageId[${packageId}] error: ${getErrorMessage(err)}`)
                    pm.reject(`write chunk[${chunkIndex + 1}/${chunkTotal}] of packageId[${packageId}] error`)
                })
                streamWrite.on('close', () => {
                    if (bOver || bWriteErr || bAbort) {
                        fsDeleteFile(pathFileChunk) //待fd關閉(close)後才刪, 否則Windows下會EBUSY; 檔案不存在視為成功且不拋錯; 寫入失敗與中斷者亦清除殘留之不完整切片
                        return
                    }
                    done()
                })

                //data
                req.payload.on('data', (chunk) => {
                    if (bOver) {
                        return
                    }
                    nReceived += chunk.length
                    if (nReceived > sizeSlice) {
                        bOver = true
                        req.payload.unpipe(streamWrite)
                        req.payload.resume() //unpipe移除最後一個目的地時Node會自動pause, 須resume才會繼續讀完剩餘本體並觸發end
                        streamWrite.destroy()
                    }
                })

                //pipe
                req.payload.pipe(streamWrite)
                // console.log(`receiving chunk[${chunkIndex + 1}/${chunkTotal}]...`)

                //end
                req.payload.on('end', () => {
                    // console.log(`receive chunk[${chunkIndex + 1}/${chunkTotal}] done`)
                    bEnded = true

                    //check, 寫入失敗者已於streamWrite error中reject
                    if (bWriteErr) {
                        return
                    }

                    //check, 超限者於此reject, 由handler回413; 成功不於此 resolve, 須待寫入串流 close(見 done)
                    if (bOver) {
                        pm.reject('payload too large')
                    }

                })

                //onAbort, 源串流出錯(如aborted/ECONNRESET)或未end即close皆為中斷, 只處理一次: 關閉寫入fd(其close會刪除不完整切片)、emit一則error事件、reject;
                //已正常end或已由寫入失敗路徑處置者不再處理; 超限(bOver)排空中斷線者仍須reject使handler結束(413已無法送達, 但不可懸置)
                let onAbort = (msg) => {
                    if (bAbort || bEnded || bWriteErr) {
                        return
                    }
                    bAbort = true
                    streamWrite.destroy()
                    evEmit('error', `receive chunk[${chunkIndex + 1}/${chunkTotal}] of packageId[${packageId}] error: ${msg}`)
                    pm.reject(`receive chunk[${chunkIndex + 1}/${chunkTotal}] of packageId[${packageId}] error: ${msg}`)
                }

                //error
                req.payload.on('error', (err) => {
                    console.log(`apiUploadSlice req.payload chunk[${chunkIndex + 1}/${chunkTotal}] of packageId[${packageId}] err`, err) //使用err.message會過於簡化, 另外要開啟顯示err供debug
                    onAbort(getErrorMessage(err))
                })

                //close, 未end即close亦視為中斷(部分中斷路徑只發close不發error); 正常路徑end先於close, 由bEnded擋下
                req.payload.on('close', () => {
                    onAbort('closed before end')
                })

                return pm
            }

            //receive
            let out = {}
            let returnType = ''
            let returnMsg = ''
            try {
                let r = await receive()
                out.success = r
                returnType = 'success'
                returnMsg = 'need to parse'
            }
            catch (err) {

                //check, 超過sizeSlice(無Content-Length之本體由receive自行計數攔截), 比照hapi對有Content-Length者之處置回413, 使前端不論送法皆收到同一種結果
                if (err === 'payload too large') {
                    return res.response({ statusCode: 413, error: 'Request Entity Too Large', message: `Payload content length greater than maximum allowed: ${sizeSlice}` }).code(413)
                }

                //其餘錯誤(源串流出錯或中斷、寫入失敗)已於receive內各路徑各emit一則error事件(含底層訊息), 此處只組回應不再emit, 否則同一失敗會有兩則(且寫入失敗者兩則文字不同, 形同兩次故障)
                out.error = err
                returnType = 'error'
                returnMsg = 'need to parse'
            }
            // console.log('out', out)

            //u8aOut
            let u8aOut = obj2u8arr(out)
            // console.log('u8aOut', u8aOut)

            // //測試失敗重傳
            // if (Math.random() < 0.6) {
            //     out = {
            //         error: 'force error'
            //     }
            //     returnType = 'error'
            //     returnMsg = 'force error'
            //     // console.log('out', out)
            //     u8aOut = obj2u8arr(out)
            // }

            return responseU8aStream(res, u8aOut, { returnType, returnMsg })
        },
    }

    //apiDownloadGetFilename
    let apiDownloadGetFilename = {
        path: `/${apiName}/dwgfn`,
        method: 'POST',
        options: {
            payload: {
                maxBytes: sizeMsg, //控制用JSON({fileId}), parse:true整包讀入記憶體, 與/main共用上限, 原為1tb形同無上限
                maxParts: 1000 * 1000 * 1000, //預設為1000, 給予3次方
                timeout: false, //避免請求未完成時中斷
                // output: 'stream',
                parse: true, //前端送obj過來須自動解析
                failAction: failPayload(routeSpec.dwgfn), //解析失敗回套件錯誤封包(見 failPayload)
            },
            timeout: {
                server: false, //關閉伺服器超時
                socket: false, //關閉socket超時
            },
        },
        handler: async function (req, res) {
            // console.log(req, res)
            // console.log('payload', req.payload)

            //spec, reply, ctx, admit: 路由前置, 見 routeSpec / ctxOf / replyOf / admit
            let spec = routeSpec.dwgfn
            let reply = replyOf(res, spec)
            let ctx = ctxOf(req, spec)
            let denied = await admit(req, ctx, spec, reply)
            if (denied) {
                return denied
            }

            //fileId, 從payload接收
            let fileId = get(req, 'payload.fileId', '')
            // console.log('fileId', fileId)

            //check
            if (!isestr(fileId)) {
                // console.log('invalid fileId in payload')
                return reply.param('invalid fileId in payload')
            }

            //inp, token 自 ctx 取得(來源與前綴檢核見 ctxOf), 供外部download事件進行授權檢查
            let inp = { fileId, token: ctx.token }

            //procDownload
            let out = {}
            await procDownload(inp)
                .then((res) => {
                    out.success = res
                })
                .catch((err) => {
                    out.error = err
                })
            // console.log('out', out)

            //return, 應用端拒絕(或其監聽器拋錯經safe emitter轉為拒絕)者於此結束, 與/dwgf、/dw同一控制流
            //why: 原無此早返而讓拒絕值落入下方之形狀檢核, 造成兩個問題 —— 一是回前端之訊息為'invalid filename'而非另兩路由之'can not get file from fileId'(同一失敗三路由兩種說法),
            //二是監聽器拋錯時safe emitter(見funGetListenerError)已發過一則error事件, 若再於形狀檢核處補發即成同一次請求兩則. 先分流拒絕, 形狀檢核才只處理「真的resolve了但形狀不對」
            if (haskey(out, 'error')) {
                // console.log('out.error', out.error)
                return reply.app('can not get file from fileId')
            }

            //ro, 依 spec.fields 之順序讀取與檢核(streamRead → filename), 失敗即銷毀串流 + 一則事件 + 錯誤回覆(見 readOutput)
            //本路由只取檔名與串流兩欄, 不讀 fileSize / fileType, 以免其 getter 拋錯影響本路由(維持既有行為, 順序寫在 routeSpec)
            //filename 須經 validDownloadField 轉為字串基本型才可放進封包(否則帶 Symbol.toStringTag='String' 且 toJSON 回 undefined 之物件會使 filename 鍵於序列化時消失, 第九輪 F2)
            let ro = readOutput(get(out, 'success'), fileId, spec, reply)
            if (!ro.ok) {
                return ro.res
            }

            //destroy, 本路由只取檔名不提供stream故須預先destroy; 瀏覽器下載管理器路徑接著會以同一fileId再觸發一次download事件取串流, 應用端每次皆須交出新串流
            destroyStreamRead(ro.v.streamRead)

            //重新提供out: 只交出檔名, 應用端回傳物件之其他欄位不外送; 收尾經 replyPacket(須經 encodeOut 而非直接 obj2u8arr:
            //filename 來自應用端, 其 toJSON 若回 BigInt 則序列化拋錯、回 undefined 則 filename 鍵消失, 兩者於直接 obj2u8arr 下皆是「宣稱成功之壞封包」且 0 則事件)
            return replyPacket(res, reply, Promise.resolve({ filename: ro.v.filename }), `download fileId[${fileId}]`)
        },
    }

    //apiDownloadGetFile
    let apiDownloadGetFile = {
        path: `/${apiName}/dwgf`,
        method: 'GET',
        options: {
            timeout: {
                server: false, //關閉伺服器超時
                socket: false, //關閉socket超時
            },
            //response.emptyStatusCode, 見/dw之同一設定
            response: {
                emptyStatusCode: 200,
            },
        },
        handler: async function (req, res) {
            // console.log(req, res)
            // console.log('payload', req.payload)

            //spec, reply, ctx, admit: 路由前置, 見 routeSpec / ctxOf / replyOf / admit
            //本路由之錯誤回應: 本體封包與標頭同其他路由, 唯 HTTP 狀態碼為非 2xx(routeSpec.dwgf.statusOf: 403 權限、400 參數、404 應用端無法提供檔案、500 內容不合契約)
            //why: 本路由之唯一消費者是瀏覽器下載管理器(client 之 downloadByManager 以 a[download] 導覽至此, 無任何 JS 讀其本體或標頭),
            //而下載管理器只以狀態碼判定成敗 —— 原本錯誤一律 HTTP 200, 應用端拒絕、permission denied 皆被存成使用者之檔案
            //(Chromium failure=null, 存下 52B / 41B 之錯誤封包; 實測第十輪 A9), 兩端 0 則事件。
            //帳本 R6「一律 HTTP 200」之前提為「由 JS 解析本體」, 於本路由不成立, 列為 R6 之例外。對標: S3 預簽 URL 以 403/404 使瀏覽器顯示下載失敗
            //token 走 query string(下載由瀏覽器導覽, 無標頭可用), authorization 由 ctxOf 合成
            let spec = routeSpec.dwgf
            let reply = replyOf(res, spec)
            let ctx = ctxOf(req, spec)
            let denied = await admit(req, ctx, spec, reply)
            if (denied) {
                return denied
            }

            //fileId
            let fileId = get(ctx.query, 'fileId', '')
            fileId = isestr(fileId) ? fileId : ''
            // console.log('fileId', fileId)

            //check
            if (!isestr(fileId)) {
                // console.log('invalid fileId in query')
                return reply.param('invalid fileId in query')
            }

            //inp, token供外部download事件進行授權檢查
            let inp = { fileId, token: ctx.token }

            //procDownload
            let out = {}
            await procDownload(inp)
                .then((res) => {
                    out.success = res
                })
                .catch((err) => {
                    out.error = err
                })
            // console.log('out', out)

            //return
            if (haskey(out, 'error')) {
                // console.log('out.error', out.error)
                return reply.app('can not get file from fileId')
            }

            //ro, 依 spec.fields 之順序讀取與檢核四欄(streamRead → fileSize → fileType → filename(選用)), 失敗即銷毀串流 + 一則事件 + 錯誤回覆(見 readOutput)
            //fileSize 會原樣寫入 Content-Length(須為安全非負整數, 經 cint 正規化); fileType 會原樣寫入 Content-Type(須通過標頭值驗證); 形狀錯誤皆屬應用端狀態, 依重試原則不標示 retryable
            //filename 為選用: 應用端有給則以 RFC 6266 之 filename*(值為 RFC 5987 percent-encoding)回傳, 使瀏覽器不論頁面與 API 是否同源皆以此命名
            //  why: 瀏覽器只對同源 URL 採用 <a download> 之檔名, 跨來源時忽略而以 URL 末段(dwgf)命名; 以往不給此標頭之理由(中文於 filename="..." 須 base64)是舊寫法之限制, filename* 由瀏覽器直接還原 UTF-8;
            //  判定不通過者(未給、非字串、toString 拋錯而無法取得字串)一律視為未給(原本 toString 拋錯者送出空檔名之標頭, 第十輪 A6); 判定時拋錯者(Symbol.toStringTag getter 等)與其他欄位同一處置
            let ro = readOutput(get(out, 'success'), fileId, spec, reply)
            if (!ro.ok) {
                return ro.res
            }
            let streamRead = ro.v.streamRead
            let fileSize = ro.v.fileSize
            let fileType = ro.v.fileType
            let filename = ro.v.filename

            //bs, 收斂streamRead並保證實送位元組數與fileSize一致(見buildDownloadSource)
            //forHead, 本路由為GET, hapi對GET路由自動支援HEAD; HEAD不送本體故不建計數串流(見buildDownloadSource之forHead)
            let bs = buildDownloadSource(streamRead, fileSize, (msg) => {
                evEmit('error', `download fileId[${fileId}] stream error: ${msg}`)
            }, { forHead: cstr(get(req, 'method', '')).toLowerCase() === 'head' })
            if (bs.error) {
                destroyStreamRead(streamRead)
                evEmit('error', `download fileId[${fileId}] output error: ${bs.reason}`)
                return reply.output(bs.error)
            }

            //rr
            //Content-Disposition, 一律 attachment: 本路由為下載端點, 未給 filename 者亦不得由瀏覽器依型別改為導頁(跨來源時 <a download> 之檔名與強制下載皆被忽略, 可直接顯示之型別即導頁, 第十一輪 N6);
            //有 filename 才加 filename*(對標 nginx / S3 / Express 之 res.download)
            let rr = res.response(bs.source)
                .type(fileType)
                .header('Content-Encoding', 'identity') //見/dw之同一設定
                .header('Content-Length', fileSize)
                .header('Content-Disposition', isestr(filename) ? `attachment; filename*=UTF-8''${encodeRfc5987(filename)}` : 'attachment')

            return rr
        },
    }

    //apiDownload
    let apiDownload = {
        path: `/${apiName}/dw`,
        method: 'POST',
        options: {
            payload: {
                maxBytes: sizeMsg, //控制用JSON({fileId}), parse:true整包讀入記憶體, 與/main共用上限, 原為1tb形同無上限
                maxParts: 1000 * 1000 * 1000, //預設為1000, 給予3次方
                timeout: false, //避免請求未完成時中斷
                // output: 'stream',
                parse: true, //前端送obj過來須自動解析
                failAction: failPayload(routeSpec.dw), //解析失敗回套件錯誤封包(見 failPayload)
            },
            timeout: {
                server: false, //關閉伺服器超時
                socket: false, //關閉socket超時
            },
            //response.emptyStatusCode, 應用端提供之0byte檔為合法檔案, 須以200+Content-Length:0表達
            //why: hapi預設emptyStatusCode為204, 會把本體長度為0之成功回應改為204並刪除Content-Length(見其transmit.js);
            //204語意為「沒有回應內容」而非「有一個長度為0的內容」, 瀏覽器下載管理器路徑(a[download]打/dwgf)因而把下載標記為取消且不落地檔案,
            //而nodejs與blob兩路徑卻正常 —— 同一download API三條交付路徑不對稱. nginx、Express之sendFile、S3取空物件皆以200+CL0表達空檔.
            //設於route層而非server層: 使自建與外部serverHapi兩種部署得到相同契約, 且不覆寫宿主其他route之政策. 錯誤封包本體非空故不受影響
            response: {
                emptyStatusCode: 200,
            },
        },
        handler: async function (req, res) {
            // console.log(req, res)
            // console.log('payload', req.payload)

            //spec, reply, ctx, admit: 路由前置, 見 routeSpec / ctxOf / replyOf / admit
            let spec = routeSpec.dw
            let reply = replyOf(res, spec)
            let ctx = ctxOf(req, spec)
            let denied = await admit(req, ctx, spec, reply)
            if (denied) {
                return denied
            }

            //fileId, 從payload接收
            let fileId = get(req, 'payload.fileId', '')
            // console.log('fileId', fileId)

            //check
            if (!isestr(fileId)) {
                // console.log('invalid fileId in payload')
                return reply.param('invalid fileId in payload')
            }

            //inp, token 自 ctx 取得(來源與前綴檢核見 ctxOf), 供外部download事件進行授權檢查
            let inp = { fileId, token: ctx.token }

            //procDownload
            let out = {}
            await procDownload(inp)
                .then((res) => {
                    out.success = res
                })
                .catch((err) => {
                    out.error = err
                })
            // console.log('out', out)

            //return
            if (haskey(out, 'error')) {
                // console.log('out.error', out.error)
                return reply.app('can not get file from fileId')
            }

            //ro, 依 spec.fields 之順序讀取與檢核四欄(streamRead → filename → fileSize → fileType), 失敗即銷毀串流 + 一則事件 + 錯誤回覆(見 readOutput)
            //filename 經 validDownloadField 轉為字串基本型並以 U+FFFD 取代孤立代理對(否則 str2b64 之寬鬆模式回空字串而檔名整個消失, 第十輪 A7);
            //fileSize 會原樣寫入 Content-Length(須為安全非負整數, 經 cint 正規化); fileType 會原樣寫入 Content-Type(須通過標頭值驗證); 形狀錯誤皆屬應用端狀態, 依重試原則不標示 retryable
            let ro = readOutput(get(out, 'success'), fileId, spec, reply)
            if (!ro.ok) {
                return ro.res
            }
            let streamRead = ro.v.streamRead
            let filename = str2b64(ro.v.filename) //headers內對中文支援度不佳須用base64傳
            let fileSize = ro.v.fileSize
            let fileType = ro.v.fileType

            //bs, 收斂streamRead並保證實送位元組數與fileSize一致(見buildDownloadSource)
            //forHead, 本路由為POST故一般不會收到HEAD; 與/dwgf同式處理, 使兩路由對此不對稱不再由「寫法差異」產生
            let bs = buildDownloadSource(streamRead, fileSize, (msg) => {
                evEmit('error', `download fileId[${fileId}] stream error: ${msg}`)
            }, { forHead: cstr(get(req, 'method', '')).toLowerCase() === 'head' })
            if (bs.error) {
                destroyStreamRead(streamRead)
                evEmit('error', `download fileId[${fileId}] output error: ${bs.reason}`)
                return reply.output(bs.error)
            }

            //Content-Encoding: identity, 使hapi跳過回應壓縮而保留Content-Length
            //why: hapi對可壓縮mime(text/*、application/json等)於前端帶Accept-Encoding時會壓縮並刪除Content-Length(見其compression.js與transmit.js);
            //而axios於nodejs預設即送Accept-Encoding, 故應用端只要宣告文字型fileType, 前端onDownloadProgress之ev.total就永遠是undefined,
            //cbProgress之prog恆為0直到結束 —— 同一download API下載二進位有進度、下載CSV/JSON卻沒有.
            //hapi之compression.encoding()於回應已帶content-encoding時即跳過壓縮, 故以此表達. 代價為文字型下載不再壓縮而傳輸量增加;
            //保留意見: RFC 9110 §8.4 之identity保留給Accept-Encoding, 於Content-Encoding為SHOULD NOT, 此為hapi 21缺乏route層停用壓縮能力下之實用途徑,
            //屬實作細節而非本套件協定之一部分, 日後hapi提供正式能力時應改用之
            return res.response(bs.source)
                .type(fileType)
                .header('Content-Encoding', 'identity')
                .header('Content-Disposition', `attachment; filename="${filename}"`) //針對前端(nodejs)用POST下載, 可基於header內base64檔名解析出並直接給予檔名, 不用預先取得檔名
                .header('Content-Length', fileSize)
        },
    }

    //startServer
    async function startServer() {

        //register inert
        if (useInert) {
            await server.register(Inert)
        }

        //apiRoutes
        let apiRoutes = []
        if (useInert) {
            let api = {
                method: 'GET',
                path: '/{file*}',
                handler: {
                    directory: {
                        path: `${pathStaticFiles}/`
                    }
                },
            }
            apiRoutes = [
                ...apiRoutes,
                api,
            ]
        }
        if (true) {
            apiRoutes = [
                ...apiRoutes,
                apiMain,
                apiUploadCheck,
                apiUploadSlice,
                // apiUploadSliceMerge,
                apiDownloadGetFilename,
                apiDownloadGetFile,
                apiDownload,
            ]
        }

        //route
        server.route(apiRoutes)

        //start
        await server.start()

        console.log(`Server running at: ${server.info.uri}`)

    }

    //start
    //pmStart, 保留建構期之啟動promise供stop等待
    //why: 原本此promise被丟棄, stop() 於啟動尚未完成時呼叫即落入 hapi 之 initializing 階段而被拒 ——
    //實測(tmp/probe_r9_final.mjs之F3): `new` 之後立刻 `await stop()` 於46ms回來、發一則
    //「Cannot stop server while in initializing phase」, 而伺服器隨後照樣起來並持續服務(port可連線且回HTTP 200);
    //亦即呼叫端以為停了而實際上沒有。本promise已帶catch故恆resolve, stop 內await它不會再拋
    let pmStart = null
    if (get(opt, 'serverHapi')) {

        //route, 註冊於外部伺服器之失敗(路由衝突、apiName 非法)須以建構期事件通知而不拋出
        //why: 自建伺服器之同一失敗於 async 之 startServer 內發生而走 error 事件, 外部伺服器原本於建構子內同步拋出 ——
        //同一種失敗依部署方式兩種通道, 與 JSDoc「啟動失敗時不拋出,以error事件通知」不合(實測第十輪 A13)
        try {
            // server.route([apiMain, apiUploadCheck, apiUploadSlice, apiUploadSliceMerge, apiDownloadGetFilename, apiDownloadGetFile, apiDownload])
            server.route([apiMain, apiUploadCheck, apiUploadSlice, apiDownloadGetFilename, apiDownloadGetFile, apiDownload])
        }
        catch (err) {
            console.log(`register routes error`, err) //使用err.message會過於簡化, 另外要開啟顯示err供debug
            evEmitDelay('error', `register routes error: ${getErrorMessage(err)}`) //建構期, 應用端尚未註冊監聽器
        }

    }
    else {
        pmStart = startServer()
            .catch((err) => {
                //埠被占用(EADDRINUSE)等啟動失敗須攔截: 未await之promise被reject即為unhandledRejection, 整個行程會崩潰且應用端無從得知; 改以error事件通知, 由應用端決定處置
                console.log(`start server error`, err)
                evEmitDelay('error', `start server error: ${getErrorMessage(err)}`) //建構期, 應用端尚未註冊監聽器
            })
    }

    //stop, 回傳promise供呼叫端等待真正停止; 其失敗以error事件通知而不外拋
    //why: 原實作為 `server.stop()` —— 既未await亦未catch, hapi之stop一旦reject即為unhandledRejection而使行程崩潰
    //(與建構期之startServer().catch為同一條規則之兩個站點, 而只有前者套了, 見帳本R11);
    //且原實作不回傳promise, 呼叫端無從得知何時真正停止(測試中wo.stop()之後立即結束時伺服器可能仍在關閉)
    //本函數之promise恆resolve(失敗走error事件), 與建構期失敗之處置方式一致, 呼叫端不需再包try
    let stop = async() => {
        try {

            //await pmStart, 須先等啟動完成才停: hapi 於 initializing 階段拒絕 stop, 而其後伺服器仍會起來並持續服務
            if (ispm(pmStart)) {
                await pmStart
            }

            await server.stop()
        }
        catch (err) {
            console.log(`stop server error`, err) //使用err.message會過於簡化, 另外要開啟顯示err供debug
            evEmit('error', `stop server error: ${getErrorMessage(err)}`)
        }
    }

    //save
    ev.stop = stop

    return ev
}


export default WConverhpServer