Appearance
useProgressStream — SSE via fetch()
Módulo: ts/core/hooks/Tipo: Hook técnico (core compartido) Estado: Implementado
Descripción
useProgressStream es un hook React genérico para consumir Server-Sent Events (SSE) sobre fetch() + ReadableStream. Recibe una URL y un body, y expone estado de progreso más una función startStream() para iniciar el stream.
El hook es completamente agnóstico de dominio: no tiene referencias a ningún módulo de negocio. Los módulos que necesitan streams de progreso crean un wrapper delgado sobre él.
Cuándo usar vs useBackgroundJob
| Criterio | useProgressStream | useBackgroundJob |
|---|---|---|
| Protocolo | SSE directo (fetch + ReadableStream) | HTTP polling cada 2 s |
| Resultado visible | Progreso en tiempo real, barra animada | Solo estado final (pending/running/completed/failed) |
| Duración típica | Segundos a pocos minutos | Minutos a horas (jobs pesados en cola) |
| Reconexión automática | No (el componente permanece montado) | Sí (sesión en sessionStorage, redescubrimiento al montar) |
| Autenticación | buildAuthHeaders() — manual, necesario para fetch() | Axios interceptors automáticos |
| Caso de uso | Facturación de lotes, importaciones con progreso visual | Jobs asíncronos de larga duración en background queue |
Regla rápida: si el usuario ve la operación en curso y espera el resultado en la misma sesión → useProgressStream. Si el proceso puede tardar muchos minutos y el usuario puede navegar o cerrar la pestaña → useBackgroundJob.
Protocolo SSE del servidor
El servidor debe emitir bloques SSE separados por \n\n:
event: progress
data: {"pct":25,"stage":"obteniendo_miembros","detail":{"members_found":150}}
event: completed
data: {"result":{...},"modo":"oficial"}
event: gap_c
data: {"cae_data":{...},"modo":"oficial"}
event: error
data: {"message":"Descripción del error"}Eventos
| Evento | Efecto en estado | Terminal |
|---|---|---|
progress | Actualiza pct, stage, detail | No |
completed | status = 'completed', pct = 100, popula result y modo | Sí |
gap_c | status = 'gap_c', popula gapCData y modo | Sí |
error | status = 'error', popula error | Sí |
Si el stream se cierra sin evento terminal, el estado pasa a error con mensaje 'connection lost'.
Estado expuesto
typescript
interface ProgressStreamState<TResult = unknown> {
status: 'idle' | 'running' | 'completed' | 'error' | 'gap_c';
pct: number; // 0–100
stage: string | null; // Nombre del paso actual
detail: Record<string, unknown> | null; // Metadata del paso
result: TResult | null; // Payload del evento completed
gapCData: unknown | null; // Payload del evento gap_c
modo: string | null; // 'oficial' | 'prueba'
error: string | null; // Mensaje de error
}Uso directo (ejemplo mínimo)
tsx
import { useProgressStream } from '../../core/hooks/useProgressStream.js';
interface MyResult { total: number; }
function MyProgressView() {
const { state, startStream } = useProgressStream<MyResult>();
const handleStart = () => {
startStream(
`${api.defaults.baseURL}mi-modulo/mi-endpoint-stream`,
{ parametro: 'valor' },
{
onComplete: (result, modo) => {
console.log('Terminó:', result, 'Modo:', modo);
},
onError: (msg) => {
console.error('Error:', msg);
},
}
);
};
if (state.status === 'idle') return <button onClick={handleStart}>Iniciar</button>;
if (state.status === 'running') return <LinearProgress value={state.pct} />;
if (state.status === 'completed') return <div>Listo: {state.result?.total}</div>;
if (state.status === 'error') return <div>Error: {state.error}</div>;
}Patrón: wrapper de dominio
La manera recomendada de usar useProgressStream en un módulo es crear un hook wrapper delgado que fije el endpoint y el tipo del resultado. Esto evita duplicar la URL y el tipo en cada componente.
Ejemplo: useBatchInvoicingProgress (ts/mod-membresias/FacturacionLotes/hooks/useBatchInvoicingProgress.ts):
typescript
import { useProgressStream } from '../../../core/hooks/useProgressStream.js';
import type { ProgressStreamState, ProgressStreamCallbacks } from '../../../core/hooks/useProgressStream.js';
import type { BatchInvoicingResponse } from '../types/facturacionLotes.types.js';
import api from '../../../api/api.js';
export type { ProgressStreamStatus } from '../../../core/hooks/useProgressStream.js';
export interface BatchProgressState extends ProgressStreamState<BatchInvoicingResponse> {}
const STREAM_ENDPOINT = `${api.defaults.baseURL ?? ''}mod-membresia/comprobantes-stream`;
export function useBatchInvoicingProgress() {
const { state, startStream: start } = useProgressStream<BatchInvoicingResponse>();
const startStream = (
body: unknown,
callbacks?: ProgressStreamCallbacks<BatchInvoicingResponse>
) => {
start(STREAM_ENDPOINT, body, callbacks);
};
return { state, startStream };
}El wrapper:
- Fija el endpoint con la misma
baseURLque Axios. - Tipea el resultado (
BatchInvoicingResponse). - Oculta el parámetro
url— el consumidor solo pasabodyy callbacks. - Re-exporta
ProgressStreamStatuspara que los consumidores no necesiten importar de core.
Por qué fetch() en vez de EventSource
EventSource (la API nativa de SSE del navegador) no permite enviar headers personalizados. El sistema Bautista requiere:
Authorization: Bearer {token}— leído de la cookieACCESS_TOKENX-Schema: {sucursal}— leído delocalStorage
fetch() con ReadableStream permite adjuntar estos headers explícitamente a través de buildAuthHeaders(), que replica la misma lógica que los interceptores Axios pero para requests nativos.
typescript
// buildAuthHeaders() — ts/api/buildAuthHeaders.ts
// Lee ACCESS_TOKEN de cookie y bautista_selected_sucursal de localStorage
// Devuelve { 'Content-Type', 'Authorization', 'X-Schema' }
const response = await fetch(url, {
method: 'POST',
headers: buildAuthHeaders(),
body: JSON.stringify(payload),
signal: controller.signal,
});Comportamiento de seguridad de navegación
Mientras el stream está en estado 'running', el hook registra dos listeners:
beforeunload— activa el diálogo nativo de "¿salir de la página?" del browser.hashchange— muestra unconfirm()manual y llamahistory.back()si el usuario cancela.
Ambos se limpian automáticamente al salir del estado 'running' (al completarse, fallar, o desmontar el componente).
Implementación del controller SSE en PHP
El endpoint del servidor debe emitir SSE sobre la misma conexión HTTP. El patrón en bautista-backend usa insertStream() en el controller:
php
// En el controller (Slim 4)
public function comprobantesStream(Request $request, Response $response): Response
{
$body = $request->getParsedBody();
return $this->orchestrator->insertStream(
$response,
$body,
function (SseEmitter $emitter) use ($body): void {
// Emitir progreso
$emitter->progress(10, 'preparando_contexto');
// ... lógica de negocio ...
$emitter->progress(50, 'procesando_lotes', ['members_found' => $count]);
// Emitir resultado final
$emitter->completed($result, $modo);
}
);
}El SseEmitter emite cada evento con el formato event: {type}\ndata: {json}\n\n y llama a ob_flush() + flush() para enviar inmediatamente cada chunk.
Referencias
- Implementación:
ts/core/hooks/useProgressStream.ts - Wrapper de dominio:
ts/mod-membresias/FacturacionLotes/hooks/useBatchInvoicingProgress.ts - Display component:
ts/mod-membresias/FacturacionLotes/components/BatchProgressDisplay.tsx - Tests:
tests/unit/mod-membresias/FacturacionLotes/hooks/useBatchInvoicingProgress.test.ts - Auth headers para fetch:
ts/api/buildAuthHeaders.ts - Hook alternativo (jobs en cola):
ts/core/hooks/useBackgroundJob.ts