Skip to content

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

CriteriouseProgressStreamuseBackgroundJob
ProtocoloSSE directo (fetch + ReadableStream)HTTP polling cada 2 s
Resultado visibleProgreso en tiempo real, barra animadaSolo estado final (pending/running/completed/failed)
Duración típicaSegundos a pocos minutosMinutos a horas (jobs pesados en cola)
Reconexión automáticaNo (el componente permanece montado)Sí (sesión en sessionStorage, redescubrimiento al montar)
AutenticaciónbuildAuthHeaders() — manual, necesario para fetch()Axios interceptors automáticos
Caso de usoFacturación de lotes, importaciones con progreso visualJobs 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

EventoEfecto en estadoTerminal
progressActualiza pct, stage, detailNo
completedstatus = 'completed', pct = 100, popula result y modo
gap_cstatus = 'gap_c', popula gapCData y modo
errorstatus = 'error', popula error

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:

  1. Fija el endpoint con la misma baseURL que Axios.
  2. Tipea el resultado (BatchInvoicingResponse).
  3. Oculta el parámetro url — el consumidor solo pasa body y callbacks.
  4. Re-exporta ProgressStreamStatus para 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 cookie ACCESS_TOKEN
  • X-Schema: {sucursal} — leído de localStorage

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 un confirm() manual y llama history.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