import { createRunner } from '../../systems-lab/src/runner.js'; export const CHANNEL = 'docs:backend:request'; export const CONTRACT = 'docs:backend:service'; /** Fixed virtual routes. Status and headers form a response contract, not an HTTP listener. */ export function createBackend({ onTrace, onWorkers, onState }) { const active = new Set(); let disposed = false; const respond = (status, body, execution = null) => ({ status, headers: { 'content-type': 'application/json' }, body, execution }); const trace = (stage, detail) => { if (!disposed) onTrace({ stage, detail }); }; return { async handle(request, context) { if (disposed) throw new Error('El proveedor está desmontado.'); trace('RPC', `${context.caller.appName} → ${context.channel} @${context.version}`); if (!request || typeof request.path !== 'string' || typeof request.body !== 'string') return respond(400, { error: 'Solicitud inválida' }); if (request.method !== 'POST') return respond(405, { error: 'Usa POST' }); if (new TextEncoder().encode(request.body).length > 4096) return respond(413, { error: 'Límite de 4096 bytes UTF-8' }); const routes = { '/validate': 'manifest', '/uppercase': 'uppercase', '/failure': 'fail', '/slow': 'spin' }; if (!Object.hasOwn(routes, request.path)) return respond(404, { error: 'Ruta virtual no encontrada' }); trace('Ruta', `${request.method} ${request.path} → WASI ${routes[request.path]}`); return new Promise((resolve, reject) => { let settled = false; let runner; const finish = (error, result) => { if (settled) return; settled = true; context.signal.removeEventListener('abort', abort); runner.dispose(); active.delete(cancel); if (error) reject(error); else resolve(result); }; const abort = () => { trace('Cancelación', 'AbortSignal de WU → Worker.terminate()'); runner.cancel(); }; const cancel = () => finish(new Error('Proveedor liberado durante la solicitud.')); runner = createRunner({ workerUrl: '/microfrontends/systems-lab/worker.js', timeoutMs: 2500, onWorkers, onState(change) { if (!disposed) onState(change); }, onResult(receipt) { if (receipt.outcome === 'cancelled') { finish(context.signal.reason || new Error('Solicitud cancelada')); return; } if (receipt.outcome !== 'completed') { finish(null, respond(receipt.outcome === 'timeout' ? 504 : 500, { error: receipt.stderr }, receipt)); return; } let body; if (request.path === '/validate') { try { body = JSON.parse(receipt.stdout); } catch { finish(null, respond(500, { error: 'El invitado no devolvió JSON válido' }, receipt)); return; } } else body = { output: receipt.stdout, error: receipt.stderr || undefined }; const status = receipt.exitCode === 0 ? 200 : receipt.exitCode === 2 ? 422 : 500; trace('WASI', `exit=${receipt.exitCode}; ${receipt.syscalls.length} llamadas al host; Worker liberado`); finish(null, respond(status, body, receipt)); }, }); active.add(cancel); context.signal.addEventListener('abort', abort, { once: true }); if (context.signal.aborted) { finish(context.signal.reason || new Error('Solicitud cancelada')); return; } try { runner.run({ mode: 'wasi', operation: routes[request.path], text: request.body }); } catch (error) { finish(error); } }); }, dispose() { disposed = true; for (const cancel of [...active]) cancel(); }, }; }