#!/usr/bin/env node // Node 22+ adapter for cloud runtimes without WebSocket. The gateway owns caching and access control. import {createServer} from 'node:http'; import {timingSafeEqual,webcrypto} from 'node:crypto'; import {readFileSync,statSync} from 'node:fs'; import {homedir} from 'node:os'; import {resolve} from 'node:path'; import {pathToFileURL} from 'node:url'; import vm from 'node:vm'; export function createWordAudioWorker(config,synthesize) { if(String(config.token||'').length<32)throw Error('TTS worker token must have at least 32 characters'); const expected=Buffer.from('Bearer '+config.token);let active=0; const maxConcurrent=Math.max(1,Math.min(20,Number(config.concurrency)||1)); const recent=[]; const send=(res,status,body)=>{res.writeHead(status,{'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify(body));}; const server=createServer(async(req,res)=>{ if(req.url==='/healthz'&&req.method==='GET')return send(res,200,{ok:true}); if(req.url!=='/synthesize'||req.method!=='POST')return send(res,404,{error:'not_found'}); const supplied=Buffer.from(String(req.headers.authorization||'')); if(supplied.length!==expected.length||!timingSafeEqual(supplied,expected))return send(res,401,{error:'unauthorized'}); if(!/^application\/json(?:;|$)/i.test(String(req.headers['content-type']||'')))return send(res,415,{error:'json_required'}); const chunks=[];let bytes=0; try{for await(const chunk of req){bytes+=chunk.length;if(bytes>4096){send(res,413,{error:'too_large'});req.destroy();return;}chunks.push(chunk);}}catch(_){return;} let input;try{input=JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch(_){return send(res,400,{error:'invalid_json'});} const word=String(input.word||'').normalize('NFC').trim().replace(/\s+/g,' '); if(!word||word.length>200||/[\x00-\x1f<>\[\]{}]/.test(word)||input.voice!==config.voice||Number(input.speed)!==50)return send(res,400,{error:'invalid_word_or_voice'}); const now=Date.now();while(recent.length&&recent[0]=maxConcurrent||recent.length>=60)return send(res,429,{error:'busy'}); active++;recent.push(now); try{const audio=await synthesize(word);send(res,200,{audio});} catch(_){send(res,502,{error:'synthesis_failed'});} finally{active--;} }); server.requestTimeout=10000;server.headersTimeout=10000;server.keepAliveTimeout=5000; return server; } async function main(){ if(typeof WebSocket==='undefined')throw Error('请使用 Node.js 22 或更新版本'); const file=resolve(process.env.XIAOSHU_TTS_CONFIG_FILE||homedir()+'/.config/xiaoshu/xfyun-word-audio.json'); if(statSync(file).mode&0o077)throw Error('配置文件权限须为600'); const settings=JSON.parse(readFileSync(file,'utf8')); const ctx=vm.createContext({crypto:webcrypto,WebSocket,TextEncoder,URL,URLSearchParams,Uint8Array,btoa,atob,AbortSignal,setTimeout,clearTimeout,Parse:{Config:{get:async()=>({get:key=>settings[key]})}},fail:(_code,message)=>{throw Error(message);}}); vm.runInContext(readFileSync(new URL('./cloud/word-audio.js',import.meta.url),'utf8'),ctx); const config=await ctx.wordAudioConfig(); if(!config.appId||!config.apiKey||!config.apiSecret||!config.voice)throw Error('讯飞配置不完整'); const server=createWordAudioWorker({token:settings.XIAOSHU_TTS_WORKER_TOKEN,voice:config.voice,concurrency:config.concurrency},word=>ctx.wordAudioSynthesize(word,config)); const port=Number(process.env.PORT)||4381,host=process.env.HOST||'127.0.0.1'; server.listen(port,host,()=>console.log(JSON.stringify({listening:true,host,port,voice:config.voice,secretsPrinted:false}))); for(const signal of ['SIGTERM','SIGINT'])process.on(signal,()=>server.close(()=>process.exit(0))); } if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href)main().catch(()=>{console.error('TTS适配器启动失败,请检查Node版本和本机私密配置。');process.exitCode=1;});