import fs from 'fs'
import os from 'os'
import path from 'path'
import { fileURLToPath } from 'url'
import { execFile } from 'child_process'
import get from 'lodash-es/get.js'
import isstr from 'wsemi/src/isstr.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import isobj from 'wsemi/src/isobj.mjs'
import isp0int from 'wsemi/src/isp0int.mjs'
import ispnum from 'wsemi/src/ispnum.mjs'
import cint from 'wsemi/src/cint.mjs'
import cdbl from 'wsemi/src/cdbl.mjs'
import fsIsFile from 'wsemi/src/fsIsFile.mjs'
import fsIsFolder from 'wsemi/src/fsIsFolder.mjs'
import Hapi from '@hapi/hapi'
import cvMdTo from './cvMdTo.mjs'
import { toErrText, checkDocxReady, getQueueSize, cleanWorkDir, normTocReq, normKeepReq } from './utils.mjs'
//fdSelf (場景B: 內建模板隨模組所在資料夾, 不依啟動時之相對路徑漂移)
let fdSelf = path.dirname(fileURLToPath(import.meta.url))
//getWinwordCount: 取當前 WINWORD 行程數(診斷用)
//why: 殘留之 WINWORD 行程會鎖檔並使後續轉檔失敗, 健檢時一併呈現供判斷
function getWinwordCount() {
return new Promise((resolve) => {
if (process.platform !== 'win32') {
resolve(-1)
return
}
execFile('tasklist', ['/FI', 'IMAGENAME eq WINWORD.EXE', '/NH'], { windowsHide: true, timeout: 8000 }, (err, stdout) => {
if (err) {
resolve(-1)
return
}
let n = (String(stdout).match(/WINWORD\.EXE/gi) || []).length
resolve(n)
})
})
}
//toContentDisposition: 組附檔標頭(中文檔名以 RFC5987 之 filename* 提供)
function toContentDisposition(fileName) {
let ascii = fileName.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '')
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(fileName)}`
}
//isInputError: 判斷是否為輸入面錯誤(回 400), 其餘視為轉檔或環境錯誤(回 500)
//note: 前綴須與 cvMdTo/utils 之錯誤訊息一致
function isInputError(msg) {
return /^(md is empty|asset|templateName)/.test(msg)
}
/**
* 建立並啟動Markdown轉Html/Docx之API服務
*
* 以hapi提供三個端點:GET /api/health(健康檢查與環境狀態)、GET /api/selftest(實跑一次最小轉檔驗證本機Word可用)、POST /api/convert(轉檔主端點,JSON參數同cvMdTo,另可用templateName指定服務端模板或templateBase64夾帶模板;query帶download=1時單一格式直接回傳二進位檔)。
*
* 請求之keepCaption(true或{labels})轉傳cvMdTo使圖名表名與其圖表同頁;請求之toc(true或addDocxToc之設定物件)轉傳cvMdTo以添加目錄,其timeoutMs不採用(Word更新之逾時由服務端決定);兩者之結果隨回應傳回。
*
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {Integer} [opt.port=22000] 輸入服務埠號整數,給0則由系統自動配置(實際埠號見回傳之server.info.port),預設22000
* @param {String} [opt.host='0.0.0.0'] 輸入監聽位址字串,預設'0.0.0.0'
* @param {String} [opt.token=''] 輸入權杖字串,非空時所有/api/*須帶x-api-token標頭,預設''
* @param {Number} [opt.maxMb=256] 輸入請求大小上限數字,單位MB,預設256
* @param {String} [opt.dirTemplates=''] 輸入docx模板資料夾位置字串,未給則用本套件src/templates,預設''
* @param {String} [opt.dirWork=''] 輸入工作資料夾位置字串,未給則用系統暫存夾下之w-md2docx,預設''
* @param {String} [opt.templateDef='temp_tpc.docx'] 輸入預設模板檔名字串,預設'temp_tpc.docx'
* @param {Boolean} [opt.autoStart=true] 輸入是否立即啟動服務布林值,預設true
* @returns {Promise} 回傳Promise,resolve回傳{server,settings,getState,stop},其中server為hapi實例,getState為取環境狀態之async函數,stop為停止服務之async函數,reject回傳錯誤訊息
* @example
*
* import rmApiServer from 'w-md2docx/src/rmApiServer.mjs'
*
* let { server, settings } = await rmApiServer({
* port: 22000,
* token: '',
* })
* console.log(`listening on ${server.info.uri}`, settings)
*
*/
async function rmApiServer(opt = {}) {
//port (0 為由系統自動配置, 供測試等場景避免撞埠; 實際埠號由 server.info.port 取得)
let port = get(opt, 'port', null)
if (!isp0int(port)) {
port = 22000
}
port = cint(port)
//host
let host = get(opt, 'host', '')
if (!isestr(host)) {
host = '0.0.0.0'
}
host = host.trim()
//token
let token = get(opt, 'token', '')
if (!isstr(token)) {
token = ''
}
token = token.trim()
//maxMb
let maxMb = get(opt, 'maxMb', null)
if (!ispnum(maxMb)) {
maxMb = 256
}
maxMb = cdbl(maxMb)
let maxBytes = Math.round(maxMb * 1024 * 1024)
//dirTemplates
let dirTemplates = get(opt, 'dirTemplates', '')
if (!isestr(dirTemplates)) {
dirTemplates = path.resolve(fdSelf, 'templates')
}
dirTemplates = path.resolve(dirTemplates)
//dirWork
let dirWork = get(opt, 'dirWork', '')
if (!isestr(dirWork)) {
dirWork = path.join(os.tmpdir(), 'w-md2docx')
}
dirWork = path.resolve(dirWork)
//templateDef
let templateDef = get(opt, 'templateDef', '')
if (!isestr(templateDef)) {
templateDef = 'temp_tpc.docx'
}
templateDef = templateDef.trim()
//autoStart
let autoStart = get(opt, 'autoStart', true)
if (!isbol(autoStart)) {
autoStart = true
}
//isTemplateName: 是否為可用之 docx 模板檔名, 列出(listTemplates)與指定(templateName)共用此規則
//note: 排除 Word 開檔期間於同資料夾建立之擁有者檔(~$ 開頭), 如以 temp_tpc.docx 為模板轉檔時之 ~$mp_tpc.docx, 其非模板且轉檔結束即刪除
let isTemplateName = (fn) => /\.docx$/i.test(fn) && !fn.startsWith('~$')
//listTemplates: 列出可用之 docx 模板檔名
let listTemplates = () => {
if (!fsIsFolder(dirTemplates)) {
return []
}
return fs.readdirSync(dirTemplates).filter(isTemplateName)
}
//getFpTemplateDef: 取預設模板位置(不存在則回空字串)
let getFpTemplateDef = () => {
let fp = path.resolve(dirTemplates, path.basename(templateDef))
return fsIsFile(fp) ? fp : ''
}
//pickTemplate: 決定本次使用之模板
//順序: 請求端夾帶(base64) > 請求指定之服務端模板名 > 服務端預設模板 > 空(交由底層自行決定)
//回傳 { fpInTemp, from, fpTmpDel } ; fpTmpDel 為須於用畢刪除之暫存模板檔
let pickTemplate = (inp) => {
//請求端夾帶
let b64 = get(inp, 'templateBase64', '')
if (!isestr(b64)) {
b64 = get(inp, 'template.base64', '')
}
if (isestr(b64)) {
b64 = b64.replace(/^data:[^;]+;base64,/, '')
fs.mkdirSync(dirWork, { recursive: true })
let fp = path.resolve(dirWork, `tmpl_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.docx`)
fs.writeFileSync(fp, Buffer.from(b64, 'base64'))
return { fpInTemp: fp, from: 'request', fpTmpDel: fp }
}
//指定服務端模板名
let fn = get(inp, 'templateName', '')
if (isestr(fn)) {
fn = fn.trim()
}
else {
fn = ''
}
if (fn !== '') {
let fp = path.resolve(dirTemplates, path.basename(fn)) //basename 化, 限定取用 templates 內之檔案
if (!isTemplateName(path.basename(fn)) || !fsIsFile(fp)) {
throw new Error(`templateName[${fn}] does not exist (available templates: ${listTemplates().join(', ') || 'none'})`)
}
return { fpInTemp: fp, from: 'server', fpTmpDel: '' }
}
//服務端預設模板
let fpDef = getFpTemplateDef()
if (fpDef !== '') {
return { fpInTemp: fpDef, from: 'default', fpTmpDel: '' }
}
return { fpInTemp: '', from: 'none', fpTmpDel: '' }
}
//getState: 取服務環境狀態
let getState = async () => {
let rd = checkDocxReady()
let nWinword = await getWinwordCount()
return {
platform: rd.platform,
node: process.version,
cwd: rd.cwd,
exeFound: rd.exeFound, //轉檔器目前是否已存在(診斷用), false 時於首次轉檔由 w-html2docx 自動下載
exePath: rd.exePath,
docxReady: rd.ready, //僅代表環境具備條件, 轉檔器可否下載與 Word 可用性須以 /api/selftest 實轉驗證
templateDefault: getFpTemplateDef(),
templates: listTemplates(),
dirTemplates,
dirWork,
queue: getQueueSize(),
winwordProcesses: nWinword,
maxBytes,
tokenRequired: token !== '',
}
}
//convertCore: 依請求內容轉檔(模板由服務端決定後交由核心處理)
let convertCore = async (inp) => {
let rTemp = pickTemplate(inp)
try {
let keepTmp = get(inp, 'keepTmp', false)
let keepWork = get(inp, 'keepWork', false)
let r = await cvMdTo({
md: get(inp, 'md', ''),
mdBase64: get(inp, 'mdBase64', ''),
name: get(inp, 'name', ''),
out: get(inp, 'out', ''),
assets: get(inp, 'assets', []),
dirWork,
fpInTemp: rTemp.fpInTemp,
optMd2html: get(inp, 'optMd2html', {}),
optHtml2docx: get(inp, 'optHtml2docx', {}),
keepWork: (keepTmp === true || keepTmp === 'true' || keepWork === true || keepWork === 'true'),
keepCaption: normKeepReq(get(inp, 'keepCaption', false)), //圖名表名與其圖表同頁
toc: normTocReq(get(inp, 'toc', false)), //添加目錄(請求端之 timeoutMs 不採用)
})
return {
success: true,
...r,
template: rTemp.from,
}
}
finally {
//刪除本次夾帶之暫存模板
if (rTemp.fpTmpDel !== '' && fsIsFile(rTemp.fpTmpDel)) {
try {
fs.unlinkSync(rTemp.fpTmpDel)
}
catch (err) {
//殘檔被鎖, 留待下次清理
}
}
}
}
//清除逾時殘留之作業資料夾
let nClean = cleanWorkDir(dirWork)
//server
let server = Hapi.server({
port,
host,
routes: {
cors: true, //內網工具服務, 便於自瀏覽器或他機測試
payload: {
maxBytes,
},
},
})
//token 驗證(未設定時不啟用)
server.ext('onRequest', (req, h) => {
if (token === '') {
return h.continue
}
if (!req.path.startsWith('/api/')) {
return h.continue
}
let t = get(req, 'headers.x-api-token', '')
if (t !== token) {
return h.response({ success: false, error: 'unauthorized: a valid x-api-token header is required' }).code(401).takeover()
}
return h.continue
})
//apis
server.route([
{
//服務說明(供瀏覽器直接開啟確認服務存活)
method: 'GET',
path: '/',
handler: () => {
return {
service: 'w-md2docx',
description: 'API service for converting Markdown to HTML/DOCX',
endpoints: {
'GET /api/health': 'health check and environment state',
'GET /api/selftest': 'run a minimal conversion to verify that Microsoft Word is available',
'POST /api/convert': 'main conversion endpoint (JSON)',
},
}
},
},
{
method: 'GET',
path: '/api/health',
handler: async () => {
let st = await getState()
return {
success: true,
...st,
}
},
},
{
//實跑一次最小 md -> docx, 驗證本機 Word 確實可用(健檢僅檢查靜態條件, 無法確認 Word 能否被調用)
method: 'GET',
path: '/api/selftest',
handler: async (req, h) => {
let msStart = Date.now()
try {
let r = await convertCore({
md: '# selftest\n\nThis file is generated by the self test of the conversion service.\n',
name: 'selftest',
out: 'both',
})
return {
success: true,
ms: Date.now() - msStart,
htmlSize: get(r, 'html.size', 0),
docxSize: get(r, 'docx.size', 0),
template: r.template,
}
}
catch (err) {
let st = await getState()
return h.response({
success: false,
error: toErrText(err),
ms: Date.now() - msStart,
env: st,
}).code(500)
}
},
},
{
method: 'POST',
path: '/api/convert',
options: {
payload: {
maxBytes,
parse: true,
allow: 'application/json',
timeout: false, //允許大檔慢速上傳
},
timeout: {
socket: false, //轉檔耗時較長, 不以 socket 逾時中斷
},
},
handler: async (req, h) => {
let inp = req.payload
if (!isobj(inp)) {
return h.response({ success: false, error: 'parameters must be provided as a JSON object' }).code(400)
}
//download: 單一格式時可直接回傳二進位檔(便於 curl -o 與瀏覽器下載)
//why: 不合法組合(out='both')於轉檔前即擋下, 避免先跑完耗時之 Word 轉檔才回 400
let qd = get(req, 'query.download', '')
let download = (qd === '1' || qd === 'true')
if (download && get(inp, 'out', '') === 'both') {
return h.response({ success: false, error: 'download only supports out of html or docx (single format)' }).code(400)
}
try {
let r = await convertCore(inp)
if (download) {
let one = null
if (r.out === 'docx' && isobj(r.docx)) {
one = r.docx
}
else if (r.out === 'html' && isobj(r.html)) {
one = r.html
}
if (one === null) {
return h.response({ success: false, error: 'download only supports out of html or docx (single format)' }).code(400)
}
return h.response(Buffer.from(one.base64, 'base64'))
.type(one.mime)
.header('content-disposition', toContentDisposition(one.fileName))
}
return r
}
catch (err) {
let msg = toErrText(err)
let code = isInputError(msg) ? 400 : 500
return h.response({ success: false, error: msg }).code(code)
}
},
},
])
//autoStart
if (autoStart) {
await server.start()
}
//定期清除逾時殘留之作業資料夾
let idInterval = setInterval(() => {
cleanWorkDir(dirWork)
}, 3600 * 1000)
idInterval.unref()
//stop
let stop = async () => {
clearInterval(idInterval)
await server.stop()
}
return {
server,
getState,
stop,
settings: { port, host, token, maxMb, maxBytes, dirTemplates, dirWork, templateDef, nClean },
}
}
export default rmApiServer