/** Same-origin read service. Production requires a gateway token, kept server-side. */ import { spawn } from 'node:child_process'; import { createServer } from 'node:http'; import { timingSafeEqual } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; const ROOT=path.dirname(fileURLToPath(import.meta.url)); export function createLibraryApi({db,registry,python=process.env.KSF_LIBRARY_PYTHON||'python',localPreview=false,token=process.env.KSF_LIBRARY_API_TOKEN}={}) { let active=0; return async (req,res)=>{ const send=(status,data)=>{res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(JSON.stringify(data));}; if(req.method!=='POST')return send(405,{ok:false,error:{code:'METHOD_NOT_ALLOWED'}}); const incoming=Buffer.from(req.headers.authorization||''),expected=Buffer.from('Bearer '+(token||'')); if(!localPreview&&(!token||incoming.length!==expected.length||!timingSafeEqual(incoming,expected)))return send(401,{ok:false,error:{code:'AUTH_REQUIRED'}}); if(active>=4)return send(429,{ok:false,error:{code:'LIBRARY_BUSY'}}); let counted=false; try { let size=0;const chunks=[]; for await(const part of req){size+=part.length;if(size>24000)throw Error('REQUEST_TOO_LARGE');chunks.push(part);} const request=JSON.parse(Buffer.concat(chunks).toString('utf8')); if(!request||typeof request!=='object'||Array.isArray(request))throw Error('INVALID_REQUEST'); if(!db&&!registry)return send(503,{ok:false,error:{code:'DATABASE_NOT_INSTALLED'}}); if(active>=4)return send(429,{ok:false,error:{code:'LIBRARY_BUSY'}}); active++;counted=true; const data=await new Promise((resolve,reject)=>{ const child=spawn(python,['-B','-X','utf8',path.join(ROOT,'voc-library/board_library.py')],{windowsHide:true,stdio:['pipe','pipe','pipe']}); let output='',bytes=0;const timeout=setTimeout(()=>{child.kill();reject(Error('LIBRARY_TIMEOUT'));},25000); child.stdout.setEncoding('utf8'); child.stdout.on('data',chunk=>{bytes+=Buffer.byteLength(chunk);if(bytes>5000000){child.kill();reject(Error('RESPONSE_TOO_LARGE'));}else output+=chunk;}); child.stderr.resume();child.stdin.on('error',()=>{}); child.on('error',()=>{clearTimeout(timeout);reject(Error('LIBRARY_RUNTIME_UNAVAILABLE'));}); child.on('close',()=>{clearTimeout(timeout);try{resolve(JSON.parse(output));}catch{reject(Error('LIBRARY_READ_FAILED'));}}); child.stdin.end(JSON.stringify({config:{db,registry},request})); }); return send(data.ok?200:400,{...data,localPreview}); }catch(e){const code=['REQUEST_TOO_LARGE','INVALID_REQUEST','LIBRARY_TIMEOUT','LIBRARY_RUNTIME_UNAVAILABLE','RESPONSE_TOO_LARGE'].includes(e.message)?e.message:'LIBRARY_READ_FAILED';return send(400,{ok:false,error:{code}});} finally{if(counted)active--;} }; } if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){ if(!process.env.KSF_LIBRARY_API_TOKEN)throw Error('KSF_LIBRARY_API_TOKEN_REQUIRED'); createServer(createLibraryApi({db:process.env.KSF_VOC_DB,registry:process.env.KSF_LIBRARY_REGISTRY})).listen(Number(process.env.KSF_LIBRARY_PORT||4180),'127.0.0.1'); }