Skip to content

Plataforma técnica · Sevastopol

BFF Proxy a Orchestrator

Sevastopol Bff Orchestrator

Sevastopol nunca habla directo con la base de datos. Toda lectura y escritura del navegador pasa por src/pages/api/, un conjunto de ~50 rutas Astro que reenvían cada request al Orchestrator. Este patrón es un Backend-for-Frontend: el frontend tiene su propia capa de servidor que adapta el contrato del backend al navegador.

Tres beneficios:

  1. CORS resuelto — el navegador siempre habla con el mismo origen que sirvió el HTML.
  2. Sesión opaca — la cookie sid viaja entre navegador y proxy; el navegador nunca recibe tokens reutilizables.
  3. Adaptación uniforme — un único helper (createProxy()) cubre el 80% de las rutas; las que necesitan lógica custom la encapsulan localmente.
PiezaArchivoRol
createProxy()src/lib/proxyUtils.tsFábrica que devuelve handlers GET/POST/PUT/DELETE/PATCH
ORCHESTRATOR_BASE_URLsrc/lib/orchestratorUrl.tsURL base del backend (env var + fallback localhost:8000)
authenticatedFetch()src/lib/authFetch.tsWrapper del lado del navegador (cookies automáticas, redirect 401)
Rutas src/pages/api/**src/pages/api/*.tsEndpoints proxy (49 usan createProxy, ~15 son custom)

sequenceDiagram
  autonumber
  participant ISL as Island (browser)
  participant FETCH as authenticatedFetch
  participant MW as Middleware
  participant PRX as createProxy handler
  participant ORC as Orchestrator
  ISL->>FETCH: GET /api/employees?tenant_id=X
  FETCH->>MW: fetch con credentials: include
  MW->>MW: ¿/api/* y sin sid? → 401
  MW->>PRX: next() (cookie sid presente)
  PRX->>PRX: getProxyHeaders(request)<br/>(cookie, content-type, authorization)
  PRX->>ORC: fetch ORCHESTRATOR_BASE_URL/api/employees?tenant_id=X
  ORC-->>PRX: 200 JSON + Set-Cookie?
  PRX->>PRX: arrayBuffer() · copia content-type<br/>content-disposition · set-cookie
  PRX-->>MW: Response
  MW->>MW: withSecurityHeaders() (CSP)
  MW-->>ISL: Response final

El helper genera un objeto con los cinco verbos HTTP apuntando al mismo handler interno:

export function createProxy(apiPath: string): {
GET?: APIRoute;
POST?: APIRoute;
PUT?: APIRoute;
DELETE?: APIRoute;
PATCH?: APIRoute;
};
  1. Construir URL destinoORCHESTRATOR_BASE_URL + apiPath más query string original.
  2. Resolver [path] dinámico — si la ruta es catch-all ([...path]), concatena el segmento dinámico.
  3. Leer body — sólo para verbos != GET/HEAD, como texto crudo (preserva forma original).
  4. Reenviar headers seleccionados — vía getProxyHeaders(): cookie, content-type, authorization. No reenvía otros headers del cliente.
  5. Responder con status y body del backend, traspasando content-type, content-disposition y set-cookie.
  6. Manejar 204 — body explícitamente null (requisito del estándar HTTP).
  7. Manejar fallo de redtry/catch global devuelve 503 Service unavailable.
function getProxyHeaders(request: Request) {
const headers: Record<string, string> = {};
const cookie = request.headers.get('cookie');
if (cookie) headers.cookie = cookie;
const contentType = request.headers.get('content-type');
if (contentType) headers['content-type'] = contentType;
const authorization = request.headers.get('authorization');
if (authorization) headers.authorization = authorization;
return headers;
}

Esta lista es deliberadamente corta. Pasar headers del navegador sin filtrar es una vía de smuggling — el proxy se queda con lo mínimo que Orchestrator necesita.

El body de la respuesta se lee como ArrayBuffer, no como JSON:

const buffer = await orchestratorRes.arrayBuffer();
// ...
return new Response(buffer, { status, headers });

Esto permite que el mismo proxy sirva JSON, PDFs generados, Excel exportado y cualquier blob sin lógica adicional.

Cuando Orchestrator emite cookies (login, refresh de sesión), el proxy las propaga al navegador:

const setCookie = orchestratorRes.headers.get('set-cookie');
if (setCookie) headers['Set-Cookie'] = setCookie;

Sin esto, /api/auth/login no podría establecer sid en el navegador.


49 de las 64 rutas siguen este patrón. La ruta de Astro es una línea:

src/pages/api/employees.ts
import { createProxy } from '@/lib/proxyUtils';
export const { GET, POST, PUT, DELETE } = createProxy('/api/employees');

Cuándo usar: cuando el contrato del frontend con Orchestrator es 1-a-1 y no necesita transformación.

Reutiliza el handler genérico para verbos comunes y reemplaza el problemático:

src/pages/api/auth/login.ts
export const GET: APIRoute = async () => {
return new Response(JSON.stringify({
success: false,
error: 'Use POST method for login',
}), { status: 405, headers: { 'Content-Type': 'application/json' } });
};
export const { POST } = createProxy('/api/auth/login');

Cuándo usar: cuando un verbo debe rechazarse explícitamente con mensaje custom (mejor UX que el 404 por defecto de Astro).

Rutas con transformación de payload, lógica condicional o multi-step. Replican getProxyHeaders() y la mecánica de fetch a mano:

src/pages/api/generate-pdf.ts (extracto)
export const POST: APIRoute = async ({ request }) => {
const bodyText = await request.text();
const payload = JSON.parse(bodyText);
// Filtrar el payload: Orchestrator sólo espera { html }
const orchestratorPayload = { html: payload.html };
const targetUrl = `${ORCHESTRATOR_BASE_URL}/api/generate-pdf${url.search}`;
const orchestratorRes = await fetch(targetUrl, {
method: 'POST',
headers: getProxyHeaders(request),
body: JSON.stringify(orchestratorPayload),
credentials: 'include',
});
// ...
};

Cuándo usar: cuando el contrato browser→proxy difiere del proxy→Orchestrator (filtrar campos, fusionar fuentes, validar antes de reenviar).


authenticatedFetch(): el lado del navegador

Section titled “authenticatedFetch(): el lado del navegador”

Las islands y hooks nunca llaman fetch() directamente. Usan el wrapper:

src/lib/authFetch.ts
export const authenticatedFetch = async (url: string, options: RequestInit = {}) => {
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
return fetch(url, {
...options,
headers,
credentials: 'include', // envía cookies automáticamente
});
};

Tres responsabilidades implícitas:

  1. credentials: 'include' — la cookie sid viaja con cada request.
  2. Content-Type: application/json por defecto — sobreescribible vía options.headers.
  3. Punto único de modificación — si mañana se necesita un header global (correlation ID, version), se agrega aquí.

handleAuthResponse se aplica en endpoints donde la sesión expirada debe redirigir:

export const handleAuthResponse = async (response: Response) => {
if (response.status === 401) {
window.dispatchEvent(new CustomEvent('toast:push', {
detail: { msg: '🔐 Sesión expirada - Redirigiendo al login', kind: 'error' },
}));
setTimeout(() => { window.location.href = '/'; }, 2000);
throw new Error('Session expired');
}
return response;
};

El delay de 2s permite que el toast sea visible antes de la navegación.


Una sola fuente de verdad para apuntar al backend:

src/lib/orchestratorUrl.ts
const DEFAULT_ORCHESTRATOR_BASE_URL = "http://localhost:8000";
const configuredUrl =
import.meta.env.ORCHESTRATOR_BASE_URL ||
process.env.ORCHESTRATOR_BASE_URL ||
DEFAULT_ORCHESTRATOR_BASE_URL;
export const ORCHESTRATOR_BASE_URL = configuredUrl.replace(/\/+$/, "");

Orden de resolución:

  1. import.meta.env.ORCHESTRATOR_BASE_URL — inyectada en build por Astro.
  2. process.env.ORCHESTRATOR_BASE_URL — variable del runtime Node SSR.
  3. http://localhost:8000 — fallback para desarrollo.

El replace(/\/+$/, "") normaliza removiendo barras finales — todas las rutas se concatenan asumiendo que la base no termina en /.


Las 64 rutas se agrupan por dominio:

DominioCarpeta o archivos
Autenticaciónauth/login.ts, auth/logout.ts, auth/validate.ts, auth/2fa/*
Multi-tenanttenant.ts, tenant-db/*, sessions.ts, permissions.ts
Remuneracionespayroll/*, employees.ts, contracts/*, cargos.ts, attendance.ts, vacations.ts
Previsionalesafp.ts, afc.ts, isapre.ts, apv_contracts.ts, isapre_contracts.ts, previsiones.ts
Contabilidadaccounting/*, gastos/*, inventario/*, activo-fijo/*, ciclo-contable/*
Financierosfinancieros/* (bancos, cartolas, conciliación, préstamos)
Declaraciones SIIdeclaraciones/*, declaraciones-juradas/*, honorarios/*, impuesto_2cat.ts
Reportesreportes/*, generate-pdf.ts
Adminadmin/* (capital, chart-of-accounts, company, system-config, representatives)
ETL & Filesetl/*, files/*, manual-cuentas/*
Comandoscommand/*, agent/*, parameters/*
Operacionesoperaciones.ts, monitoring.ts, working_day.ts
Catálogosmenu.ts, departments.ts

Para el catálogo exhaustivo, leer directamente sevastopol/src/pages/api/. Cada archivo es de una línea cuando usa createProxy.


  1. Confirmar que Orchestrator ya expone el endpoint (por ejemplo /api/nuevo-recurso).

  2. Crear src/pages/api/nuevo-recurso.ts:

    import { createProxy } from '@/lib/proxyUtils';
    export const { GET, POST, PUT, DELETE } = createProxy('/api/nuevo-recurso');
  3. Llamar desde una island con authenticatedFetch('/api/nuevo-recurso').

No hay paso 4. El middleware ya protege /api/* con verificación de cookie.

Para /api/recurso/:id crear src/pages/api/recurso/[id].ts. El proxy interpreta los params y los reescribe en la URL destino — ver payroll/[id].ts como referencia.

Si el payload del navegador no coincide con el que espera Orchestrator (filtrar campos, fusionar fuentes), copiar la estructura de generate-pdf.ts y adaptar el body antes del fetch.


SituaciónRespuesta del proxy
Cookie sid ausenteMiddleware responde 401 antes de llegar al handler
Orchestrator responde error HTTP (4xx/5xx)El proxy reenvía el status y body tal cual
Orchestrator inalcanzable (fetch lanza)503 Service unavailable con {success: false, error} JSON
Body de respuesta 204 No ContentResponse(null, { status: 204 }) — body debe ser null

Una falla 503 del proxy debe interpretarse en el navegador como backend caído, no como sesión expirada. Las islands diferencian con el código HTTP para decidir si mostrar toast de reconexión o redirección al login.