| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253 |
- #!/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]<now-60000)recent.shift();
- if(active>=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;});
|