# Server-Sent Events (SSE)

SSE empurra dados do servidor pro navegador por **uma conexão HTTP de
longa duração**, sem polling. É o caminho mais simples pra "tempo real
unidirecional": feed de notificações, barra de progresso, ticker de
preço, logs ao vivo.

!!! info "SSE vs WebSocket vs Web Push"
    - **SSE** — servidor → cliente, só texto, reconecta sozinho, roda
      sobre HTTP comum. Use quando o cliente só **recebe**.
    - **WebSocket** — bidirecional, binário, mais complexo. Use quando o
      cliente também **envia** com frequência. Veja [WebSocket](websocket.md).
    - **Web Push** — chega com a **página fechada** (Service Worker). Veja
      [Web Push](webpush.md).

O SDK traz três peças: `EventStream` (fila async em memória que alimenta
uma conexão), `ServerSentEvent` (codifica um frame no formato do spec) e
`sse_response` (embrulha o stream num `StreamingResponse` com os headers
certos — `Cache-Control: no-cache`, `Connection: keep-alive`,
`X-Accel-Buffering: no` pra desligar o buffer do nginx).

!!! tip "Novidades da v0.91"
    - **Backpressure** — a fila do `EventStream` agora é **limitada**
      (`max_queue`, default `1000`): cliente lento não faz a memória
      crescer sem limite. A política `overflow` decide o que cai. Veja
      [Backpressure](#backpressure-fila-limitada).
    - **Lifecycle sem boilerplate** — `sse_response(..., on_disconnect=)`,
      `EventStream.response(...)` e `SSEBroker.response(channel)` fecham o
      produtor / desregistram o canal sozinhos quando o cliente cai.
    - **Auth por query string** pra clientes cookieless (`EventSource`).
      Veja [Autenticação](#autenticacao-cookie-ou-query-string).

## Um endpoint SSE

Crie um `EventStream` por requisição, publique de um produtor, e ligue o
ciclo de vida do produtor à conexão do cliente — se o cliente cai, o
produtor para.

```python
# src/api/routers/events.py
import asyncio

from fastapi import APIRouter
from starlette.responses import StreamingResponse

from tempest_fastapi_sdk import EventStream

router = APIRouter()


@router.get("/events")
async def events() -> StreamingResponse:
    """Emite 3 frames SSE e fecha o stream."""
    stream = EventStream(heartbeat_seconds=15.0)

    async def producer() -> None:
        try:
            for n in range(1, 4):
                await stream.publish({"n": n}, event="counter", id=str(n))
                await asyncio.sleep(1)
        finally:
            await stream.close()

    task = asyncio.create_task(producer())

    # on_disconnect roda quando o cliente cai OU o stream termina:
    # é onde você cancela o produtor pra não vazar.
    return stream.response(on_disconnect=task.cancel)
```

!!! warning "Sempre amarre o produtor à conexão"
    Stream SSE é longo. Se o cliente desconecta no meio, você não quer o
    produtor rodando pra sempre. Passe `on_disconnect=` pro
    `EventStream.response` (ou pro `sse_response`) — ele roda no `finally`
    do gerador da resposta, o único ponto que dispara na desconexão.

??? note "Antes da v0.91: `try/finally` na mão"
    Até a v0.90 você embrulhava o `stream()` num gerador externo só pra
    ter o `finally`. `on_disconnect=` substitui esse boilerplate:

    ```python
    from collections.abc import AsyncIterator
    from tempest_fastapi_sdk import sse_response

    async def lifecycle_aware() -> AsyncIterator[bytes]:
        try:
            async for chunk in stream.stream():
                yield chunk
        finally:
            task.cancel()

    return sse_response(lifecycle_aware())
    ```

## Anatomia de um evento

`publish()` aceita os quatro campos do spec:

```python
await stream.publish(
    {"orderId": "abc", "status": "paid"},  # data: vira JSON automático
    event="order_update",                  # event: nome do listener no front
    id="42",                               # id: vira Last-Event-ID (resume)
    retry=3000,                            # retry: dica de reconexão (ms)
)
```

| Campo | Pro quê serve |
| --- | --- |
| `data` | Payload. String/bytes vão crus; qualquer objeto vira JSON. |
| `event` | Nome do evento — o front escuta com `addEventListener(name)`. Sem isso, cai no `"message"`. |
| `id` | Vira `Last-Event-ID`; o navegador reenvia no reconnect pra você retomar. |
| `retry` | Atraso de reconexão sugerido (ms). |

`heartbeat_seconds` emite um **comentário** SSE (`: keepalive`) quando o
stream fica ocioso, pra load-balancers não cortarem a conexão.
Comentários são **invisíveis** ao `EventSource` — não disparam nenhum
listener, só mantêm o socket vivo. `None` desliga o heartbeat.

## Backpressure (fila limitada)

Se um cliente **para de ler** (aba em segundo plano, rede ruim) mas o
produtor continua publicando, a fila do `EventStream` cresceria pra
sempre — vazamento de memória clássico. Por isso a fila é **limitada**:
`max_queue` (default `1000`) e uma política `overflow` que decide o que
fazer quando enche.

```python
from tempest_fastapi_sdk import EventStream

# Ticker ao vivo: frame velho não vale nada -> descarta o mais antigo.
stream = EventStream(max_queue=500, overflow="drop_oldest")
```

| `overflow` | Quando a fila enche | Use quando |
| --- | --- | --- |
| `"drop_oldest"` (default) | Evicta o evento mais **antigo** | Dados vivos: ticker, progresso, telemetria — só o estado recente importa. |
| `"drop_newest"` | Descarta o evento que **chegou** | O começo do fluxo importa mais que o fim. |
| `"block"` | Segura o `publish()` até abrir vaga | Produtor dedicado a **uma** conexão e perder evento é inaceitável. |

!!! danger "`block` pode travar um produtor compartilhado"
    Com `overflow="block"`, um cliente lento **segura** o `publish()`. Se
    o mesmo produtor alimenta vários clientes (fan-out), um cliente ruim
    trava todos. Só use `block` quando o produtor serve **uma** conexão.

O sentinela de `close()` **nunca** é descartado nem bloqueado — o stream
sempre encerra. `stream.dropped_events` conta quantos eventos caíram por
overflow, pra você jogar em métrica/log:

```python
if stream.dropped_events:
    logger.warning("SSE lento: %d eventos descartados", stream.dropped_events)
```

!!! tip "Voltar ao comportamento antigo"
    `max_queue=0` desliga o limite (fila ilimitada, igual pré-0.91). Só
    faça isso se você tiver certeza de que o produtor para junto com a
    conexão.

## Broadcast pra vários clientes (`SSEBroker`)

`EventStream` é **uma** conexão. Pra mandar o mesmo evento pra todos os
clientes de um canal (ex.: os devices de um usuário, ou um tópico), o SDK
traz o `SSEBroker` — registro de streams por canal + fan-out. O canal é
uma string qualquer (id de usuário, slug de sala...).

```python
# src/api/dependencies/resources.py
from tempest_fastapi_sdk import SSEBroker

broker = SSEBroker()   # singleton — guarde em app.state e injete via Depends
```

```python
# src/api/routers/feed.py
from uuid import UUID

from fastapi import APIRouter, Depends
from starlette.responses import StreamingResponse

from tempest_fastapi_sdk import SSEBroker

router = APIRouter()


@router.get("/feed")
async def feed(
    user_id: UUID = Depends(get_current_user_id),
    broker: SSEBroker = Depends(get_broker),
) -> StreamingResponse:
    """Inscreve o cliente no canal do seu usuário."""
    # register + sse_response + unregister-na-desconexão, tudo num call.
    return broker.response(str(user_id))


# De qualquer lugar (handler de fila, outro endpoint):
# await broker.publish(str(user_id), {"text": "Novo pedido"}, event="notice")
```

!!! tip "`broker.response()` mata o vazamento de stream"
    `broker.response(channel)` faz `register`, embrulha com `sse_response`
    e liga um `on_disconnect` que chama `unregister` quando o cliente cai.
    Não tem `try/finally` pra esquecer — cada desconexão limpa o registro
    sozinha. `SSEBroker(max_queue=..., overflow=...)` aplica a mesma
    política de backpressure a todo stream que ele abre.

### Multi-worker: bridge Redis (pronto, sem código extra)

O `SSEBroker` em memória vive em **um** worker — com `--workers N`, um
`publish` só alcança os clientes presos naquele processo. Passe um client
Redis e o **mesmo `broker`** passa a publicar via Redis `PUBLISH`; uma
task de fundo (`run()`) faz `PSUBSCRIBE` e repassa pros streams locais de
**cada** worker. Mesmo call site, agora horizontal:

```python
# src/api/app.py
import asyncio
from contextlib import asynccontextmanager

from fastapi import FastAPI
from redis.asyncio import Redis

from tempest_fastapi_sdk import SSEBroker

redis = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
broker = SSEBroker(redis=redis, channel_prefix="sse")


@asynccontextmanager
async def lifespan(app: FastAPI):
    task = asyncio.create_task(broker.run())   # assina o Redis e faz o fan-out
    try:
        yield
    finally:
        await broker.aclose()
        task.cancel()


app = FastAPI(lifespan=lifespan)
# broker.publish(...) em qualquer worker -> chega em TODOS os workers
```

!!! tip "Comece simples, escale depois"
    Sem Redis, `SSEBroker()` já resolve um processo. Quando precisar de
    múltiplos workers/hosts, só injete o client Redis e suba o `run()` no
    lifespan — nenhum endpoint muda. O `publish` se torna cross-process de
    graça.

## Autenticação (cookie ou query string)

Aqui mora a pegadinha do SSE: o `EventSource` nativo do navegador **não
deixa** você mandar header. Nada de `Authorization: Bearer` no handshake.
Então existem dois caminhos pra autenticar o stream.

### Caminho preferido: cookie de sessão

Se o front está **na mesma origem** da API, use um cookie `HttpOnly`. O
navegador o manda sozinho quando você abre com `withCredentials`:

```javascript
const es = new EventSource("/api/feed", { withCredentials: true });
```

No backend, o SDK já lê o token do cookie — é o mesmo seam do
`make_auth_router` (modo de entrega por cookie):

```python
# src/api/dependencies/auth.py
from tempest_fastapi_sdk import JWTUtils, make_jwt_user_dependency

tokens = JWTUtils(secret=settings.JWT_SECRET)

current_user = make_jwt_user_dependency(
    tokens,
    load_user,
    cookie_name="access_token",   # <- EventSource + withCredentials
)
```

!!! check "Por que cookie é melhor"
    O token some da URL: não vaza em log de acesso, histórico do
    navegador nem header `Referer`. `HttpOnly` ainda tira o token do
    alcance de JavaScript (defesa contra XSS). Prefira esse caminho
    **sempre** que a origem for compartilhada.

### Alternativa cookieless: token na query string

Sem cookie de sessão (front em **outra origem**, app mobile abrindo um
`EventSource` cru, ambiente onde `withCredentials` não rola), passe o
**access token** na query string. A partir da v0.91 o dependency aceita
isso via `query_param`:

```python
# src/api/dependencies/auth.py
from tempest_fastapi_sdk import JWTUtils, make_jwt_user_dependency

tokens = JWTUtils(secret=settings.JWT_SECRET)

# Ordem de busca: header -> cookie -> query string.
current_user = make_jwt_user_dependency(
    tokens,
    load_user,
    query_param="access_token",   # <- ?access_token=<jwt>
)
```

```python
# src/api/routers/feed.py
from uuid import UUID

from fastapi import APIRouter, Depends
from starlette.responses import StreamingResponse

from tempest_fastapi_sdk import SSEBroker

router = APIRouter()


@router.get("/feed")
async def feed(
    user: User = Depends(current_user),      # resolve o JWT da query string
    broker: SSEBroker = Depends(get_broker),
) -> StreamingResponse:
    """Stream autenticado sem cookie — token vem na URL."""
    return broker.response(str(user.id))
```

No front:

```javascript
// O access token curto entra na URL — nunca o refresh token.
const es = new EventSource(`/api/feed?access_token=${accessToken}`);
```

!!! danger "Query string vaza — trate o token como descartável"
    Um token na URL aparece em **log de acesso**, **histórico** e no
    header **`Referer`**. Regras inegociáveis:

    - Só **access token de vida curta** (minutos). **Nunca** o refresh
      token.
    - Sempre sobre **TLS** (HTTPS).
    - Remova o valor do formato de log do seu proxy/servidor.
    - Renove via um endpoint normal (com header/cookie), não pela query.

!!! info "`query_param` também existe no dependency de baixo nível"
    `make_bearer_token_dependency(tokens, query_param="access_token")`
    devolve só as claims decodificadas — use quando você monta o
    `get_current_user` na mão. Mesma ordem header → cookie → query.

## Alinhado com o tempest-react-sdk

O `createEventStream` / `useEventStream` do
[`tempest-react-sdk`](https://github.com/mauriciobenjamin700/tempest-react-sdk)
consome esses endpoints com reconnect (backoff exponencial) embutido:

```typescript
import { createEventStream } from "@mauriciobenjamin700/tempest-react-sdk";

const stream = createEventStream<{ text: string }>("/feed", {
    withCredentials: true,        // manda cookie de auth no handshake
    namedEvents: ["notice"],      // <- bate com publish(event="notice")
    onMessage: (m) => console.log(m.event, m.data),  // data já vem JSON-parseado
});
// stream.close() pra encerrar; stream.reconnect() pra forçar reconexão
```

!!! tip "Heartbeat: comentário vs evento `ping`"
    O heartbeat do `EventStream` é um **comentário** — o `EventSource`
    ignora, então o react-sdk nem precisa de `heartbeatEvents`. Se você
    preferir um heartbeat **nomeado** visível, publique
    `await stream.publish("", event="ping")` e configure
    `heartbeatEvents: ["ping"]` no front (default dele).

Pontos de alinhamento:

- `publish(event="x")` ↔ `namedEvents: ["x"]` + `onMessage`.
- `data` não-string vira JSON ↔ o parser default do react decodifica JSON.
- `id=` ↔ `Last-Event-ID` reenviado no reconnect (retome de onde parou).
- Auth por cookie ↔ `withCredentials: true`.

## Recap

- `EventStream` (1 por conexão) + `sse_response` — endpoint SSE com headers prontos.
- Amarre o produtor à conexão com `on_disconnect=` (em `EventStream.response`, `sse_response` ou `broker.response`) — sem `try/finally` na mão.
- Fila **limitada** (`max_queue`, default `1000`) + `overflow` (`drop_oldest`/`drop_newest`/`block`) evita vazamento por cliente lento; `dropped_events` conta o descarte.
- `publish(data, event=, id=, retry=)` cobre os 4 campos do spec; `data` não-string vira JSON.
- Heartbeat é comentário (invisível ao EventSource); `None` desliga.
- Broadcast = `SSEBroker`; `broker.response(channel)` faz register + response + unregister; multi-worker = passe um client Redis + suba `broker.run()` no lifespan.
- Auth: **cookie** (`cookie_name` + `withCredentials`) na mesma origem; **query string** (`query_param`, só access token curto sobre TLS) pra clientes cookieless.
- `tempest-react-sdk` `createEventStream`/`useEventStream` consome com reconnect; `namedEvents` ↔ `publish(event=)`.
