import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { bodyLimit } from 'hono/body-limit'; import { streamSSE } from 'hono/streaming'; export const MAX_BODY = 1024; export const DEMO_TOKEN = 'demo-anna'; // Public teaching token, NEVER a production secret. const services = ['Настройка сайта','Аудит интерфейса','Поддержка API']; export function initialData() { return Array.from({length:18},(_,i)=>({id:i+1,title:`${services[i%3]} №${i+1}`,owner:i%2?'boris':'anna',status:i%3?'open':'done',budget:5000+i*750})); } export function createApi({onEvent=()=>{}, onInternalError=()=>{}, streamDelay=280}={}) { const app = new Hono(); let records=initialData(); const fail=(c,status,code,message,fields)=>c.json({ok:false,error:{code,message,...(fields?{fields}:{})},requestId:c.get('requestId')},status); const note=(c,stage)=>onEvent({requestId:c.get('requestId'),stage}); app.use('*',async(c,next)=>{ const started=performance.now();c.set('requestId',crypto.randomUUID());note(c,'1 · requestId: до'); await next(); c.header('X-Request-Id',c.get('requestId'));c.header('X-Content-Type-Options','nosniff');c.header('Cache-Control','no-store'); c.header('Server-Timing',`app;dur=${(performance.now()-started).toFixed(2)}`);note(c,'5 · requestId: после'); }); app.use('/api/*',cors({origin:'https://demo.example',allowMethods:['GET','POST','OPTIONS'],allowHeaders:['Content-Type','Authorization'],exposeHeaders:['X-Request-Id'],maxAge:600})); app.use('/api/*',async(c,next)=>{note(c,'2 · журнал: до');await next();note(c,'4 · журнал: после');}); app.use('/api/*',bodyLimit({maxSize:MAX_BODY,onError:c=>fail(c,413,'BODY_TOO_LARGE','Тело запроса превышает 1024 байта.')})); app.get('/api/health',c=>{note(c,'3 · обработчик health');return c.json({ok:true,framework:'Hono',mode:'shared-router',records:records.length});}); app.get('/api/jobs',c=>{ note(c,'3 · обработчик списка'); const {page='1',limit='5',q='',status='all'}=c.req.query(); if(!/^[1-9]\d{0,3}$/.test(page)||!/^(?:[1-9]|10)$/.test(limit)||q.length>80||!['all','open','done'].includes(status))return fail(c,422,'QUERY_INVALID','page: 1–9999, limit: 1–10, q: до 80 символов, status: all/open/done.'); const rows=records.filter(x=>(status==='all'||x.status===status)&&x.title.toLowerCase().includes(q.toLowerCase())); const p=Number(page),l=Number(limit),start=(p-1)*l; return c.json({ok:true,data:rows.slice(start,start+l),meta:{page:p,limit:l,total:rows.length,pages:Math.ceil(rows.length/l),next:start+l{ note(c,'3 · обработчик карточки');const id=c.req.param('id'); if(!/^[1-9]\d{0,5}$/.test(id))return fail(c,422,'ID_INVALID','Идентификатор должен быть положительным целым числом.'); const row=records.find(x=>x.id===Number(id));return row?c.json({ok:true,data:row}):fail(c,404,'JOB_NOT_FOUND','Такой учебной заявки нет.'); }); app.post('/api/jobs',async c=>{ note(c,'3 · обработчик JSON'); if((c.req.header('Content-Type')||'').split(';')[0].trim().toLowerCase()!=='application/json')return fail(c,415,'CONTENT_TYPE','Используйте Content-Type: application/json.'); let data;try{data=await c.req.json();}catch{return fail(c,400,'JSON_INVALID','JSON содержит синтаксическую ошибку.');} const fields={}; if(!data||typeof data!=='object'||Array.isArray(data))return fail(c,422,'VALIDATION','Ожидается объект заявки.'); if(typeof data.title!=='string'||data.title.trim().length<3||data.title.trim().length>80)fields.title='Название: от 3 до 80 символов.'; if(!Number.isInteger(data.budget)||data.budget<0||data.budget>1000000)fields.budget='Бюджет: целое число от 0 до 1 000 000.'; if(Object.keys(data).some(k=>!['title','budget'].includes(k)))fields.extra='Допустимы только title и budget; owner назначает сервер.'; if(Object.keys(fields).length)return fail(c,422,'VALIDATION','Исправьте поля заявки.',fields); if(records.length>=50)return fail(c,409,'DEMO_FULL','Достигнут лимит демо: 50 записей. Сбросьте мастерскую или перезапустите сервер.'); const row={id:Math.max(...records.map(x=>x.id))+1,title:data.title.trim(),budget:data.budget,owner:'anna',status:'open'};records.push(row); c.header('Location',`/api/jobs/${row.id}`);return c.json({ok:true,data:row,storage:'process-memory'},201); }); app.use('/api/private/*',async(c,next)=>{ note(c,'3 · учебная аутентификация'); if(c.req.header('Authorization')!==`Bearer ${DEMO_TOKEN}`){c.header('WWW-Authenticate','Bearer realm="Hono demo"');return fail(c,401,'AUTH_REQUIRED','Нужен открытый учебный токен demo-anna.');} c.set('user',{id:'anna'});await next(); }); app.get('/api/private/jobs/:id',c=>{ const row=records.find(x=>String(x.id)===c.req.param('id')); if(!row)return fail(c,404,'JOB_NOT_FOUND','Такой учебной заявки нет.'); if(row.owner!==c.get('user').id)return fail(c,403,'FORBIDDEN','Заявка принадлежит другому пользователю.'); return c.json({ok:true,data:row,user:c.get('user').id}); }); app.get('/api/failure',()=>{throw new Error('Synthetic internal failure: never returned to client');}); app.get('/api/progress',c=>{ const raw=c.req.query('steps')||'5';if(!/^[3-8]$/.test(raw))return fail(c,422,'STEPS_INVALID','steps: целое число от 3 до 8.'); c.header('X-Accel-Buffering','no');const steps=Number(raw); return streamSSE(c,async stream=>{ stream.onAbort(()=>note(c,'Поток отменён читателем')); try { for(let i=1;i<=steps&&!stream.aborted;i++){ await stream.sleep(streamDelay);if(stream.aborted)break; await stream.writeSSE({event:'progress',id:String(i),data:JSON.stringify({step:i,total:steps,percent:Math.round(i/steps*100),message:`Проверена порция ${i} из ${steps}`})}); } if(!stream.aborted)await stream.writeSSE({event:'done',data:JSON.stringify({ok:true,message:'Учебный поток завершён. Это не фоновая очередь.'})}); } catch(err) { onInternalError({requestId:c.get('requestId'),error:String(err?.message||err)}); // Catch inside the callback: v4 helper's third-argument error handler // also writes the original exception message after calling that handler. if(!stream.aborted)try{await stream.writeSSE({event:'error',data:JSON.stringify({message:'Ошибка потока'})});}catch{/* Reader already disconnected. */} } }); }); app.notFound(c=>fail(c,404,'ROUTE_NOT_FOUND','Маршрут не найден.')); app.onError((err,c)=>{onInternalError({requestId:c.get('requestId'),error:err.message});return fail(c,500,'INTERNAL','Не удалось выполнить операцию. Сохраните requestId.');}); return app; }