WebSocket
createWebSocket + useWebSocket espelham a API de SSE, mas adicionam o canal de envio. Use quando o cliente precisar mandar mensagens, não só receber.
Quando WebSocket em vez de SSE?
SSE só recebe. Assim que o cliente precisa enviar (chat, cursores compartilhados, comandos em tempo real), você precisa de um canal bidirecional — é aí que entra o WebSocket. Se for só push servidor → cliente, SSE é mais simples.
Quando usar
- Chat / colaboração real-time.
- Bidirecional, latência baixa.
- Frames binários (também suportados via
send(ArrayBuffer)).
API imperativa — createWebSocket
import { createWebSocket } from "tempest-react-sdk";
interface ChatEvent {
user: string;
text: string;
}
const socket = createWebSocket<ChatEvent>(`${import.meta.env.VITE_WS_URL}/chat`, {
pingInterval: 30_000, // mantém o socket vivo
maxRetries: 10,
onOpen: () => console.log("ws aberto"),
onMessage: ({ data }) => render(data),
onClose: (event) => console.log("fechou", event.code),
onStatusChange: (status) => console.log("WS", status),
});
// Enviar — retorna false se o socket não estiver aberto:
const sent = socket.send(JSON.stringify({ user: "ana", text: "oi" }));
// Frame binário:
socket.send(new Uint8Array([1, 2, 3]).buffer);
// Encerrar (não tenta reconectar):
socket.close();
// Forçar reconexão imediata (zera o contador):
socket.reconnect();
onMessage recebe { data, raw } — data já vem JSON-parsed (fallback pra string), raw é o MessageEvent original.
Reconexão, ping e fechamento limpo
Backoff: 1s → 2s → 4s → ... (limitado em 30s), até maxRetries (default 10)
- Reconnect exponencial igual ao SSE (default
maxRetries: 10), com jitter de 30% (jitter, 0–1) somado a cada espera. pingInterval(ms) enviapingPayloadperiodicamente — defaultJSON.stringify({ type: "ping" }). Passe0(default) pra desativar.respondToPing(defaulttrue) respondepongPayloada todo frame{"type":"ping"}que chega. É esse o heartbeat que otempest-fastapi-sdkespera: sem opongele fecha o socket com o código4408ao estourarWS_HEARTBEAT_TIMEOUT_SECONDS.queueWhileClosed(defaultfalse) guarda o que você tentar enviar enquanto o socket está caído e drena na próxima abertura, mais antigo primeiro, com teto emmaxQueuedMessages(default 100).- Qual fechamento reabre depende do código, não só de
wasClean— a tabela está em Reconectar ou não.
Por que jitter
Quando é o servidor que caiu, todo cliente conectado a ele acorda no mesmo cronograma e retenta no mesmo milissegundo — a máquina volta direto para uma estampida sincronizada e derruba as conexões de novo. O jitter só soma tempo (o piso continua previsível) e quebra o alinhamento. Passe jitter: 0 quando quiser um cronograma exato, num teste por exemplo.
Falhas que não disparam evento
Três modos de falha não geram open, close nem error — e é por isso que uma cadeia de retry montada só sobre eventos trava.
1. O handshake que pendura. Um WebSocket que não alcança o servidor não falha: fica em CONNECTING, calado. Medido no Chrome com o backend fora, 12 s depois o readyState ainda era 0 e a lista de eventos, vazia. Uma cadeia de retry construída só sobre eventos para na primeira tentativa pendurada e nunca mais anda — e pendurar, não recusar, é exatamente como um link móvel ruim se comporta, que é o cenário para o qual a reconexão existe.
handshakeTimeout (default 8000 ms, 0 desativa) abandona a tentativa e agenda a próxima.
2. O link que morre em voo. O socket só reporta conexão que fecha limpo. Se o link morre no meio — o celular sai da cobertura, um intermediário derruba a conexão — o readyState fica em OPEN desse lado e nada mais chega. Silêncio é o único sintoma disponível.
silenceTimeout (ms, default 0 = desligado) é a janela tolerada. Ele é rearmado por qualquer frame que chega, não só por pong: tráfego é tráfego, e uma negociação em curso já prova que o link carrega dados. Use ~2,5× o intervalo de ping do servidor, para um ping perdido não ser confundido com queda.
const socket = createWebSocket(url, {
silenceTimeout: 75_000,
onMessage: ({ data }) => {
// o servidor anuncia o próprio heartbeat: não fixe o valor nos dois lados
if (data.type === "welcome") socket.setSilenceTimeout(data.heartbeat_seconds * 2500);
},
});
3. O rádio desligado. Queimar tentativa contra um rádio desligado é como um celular esgota o orçamento dentro do túnel e desiste na saída dele. Com waitForOnline (default true), enquanto navigator.onLine for false o cronograma fica suspenso e o evento online dispara a próxima tentativa.
Reconectar ou não
| Fechamento | Reabre? | Por quê |
|---|---|---|
wasClean: false (qualquer código) |
✅ | a conexão morreu, não terminou |
1001, 1011, 1012, 1013 |
✅ | servidor indo embora por um motivo que acaba (deploy, restart, sobrecarga) |
4408 |
✅ | timeout de heartbeat do tempest-fastapi-sdk — é o link falhando |
1000 / 1005 limpo |
❌ | despedida de propósito: sessão encerrada, logout, stream terminado |
4400–4499 (menos 4408) |
❌ → onLost("rejected") |
recusa: sala inválida, não autorizado, proibido, cheio |
A mesma classificação está exportada, para quando o seu código precisar inspecionar um CloseEvent por conta própria: isRejectionCloseCode(code) responde se aquele fechamento é recusa, e HEARTBEAT_CLOSE_CODE é o 4408 com nome.
import { HEARTBEAT_CLOSE_CODE, isRejectionCloseCode } from "tempest-react-sdk";
onClose: (event) => {
if (isRejectionCloseCode(event.code)) mostrarTelaDeAcessoNegado();
else if (event.code === HEARTBEAT_CLOSE_CODE) registrar("heartbeat perdido");
};
O 4408 mora dentro da faixa de recusa e não é uma
A leitura óbvia — "4400–4499 é fatal" — torna permanente um pong perdido. O tempest-fastapi-sdk fecha com 4408 quando o pong não chegou dentro de WS_HEARTBEAT_TIMEOUT_SECONDS: isso é o link falhando, exatamente o caso para o qual a reconexão existe. O SDK já abre essa exceção para você.
Reconectando ≠ erro
const socket = createWebSocket(url, {
onReconnecting: (attempt, total) => setBanner(`Reconectando ${attempt}/${total}…`),
onReconnected: () => refetchTudo(),
onLost: (reason) => setBanner(reason === "rejected" ? "Acesso negado" : "Sem conexão"),
});
onReconnecting(attempt, total)— uma tentativa foi agendada. Estado discreto, não erro: anunciar cada tentativa põe "a conexão caiu" na frente de quem está com a sessão voltando sozinha.onReconnected()— voltou depois de pelo menos uma tentativa. Nada é retomado por você: um servidor que guarda estado por conexão vê um cliente novo, então é aqui que você re-assina, re-entra ou refaz o fetch do que o intervalo invalidou.onLost(reason)— desistiu. É este que merece UI, porque é o único estado sobre o qual a pessoa pode agir: ofereça um "tentar de novo" que chamareconnect().
Entrar é diferente de cair
const socket = createWebSocket(url, { maxRetries: 0 });
try {
await socket.opened;
} catch {
mostrarErroDeEntrada();
}
opened resolve na primeira abertura e rejeita quando o socket morre sem nunca ter aberto (websocket_rejected, websocket_exhausted ou websocket_closed). Falhar ao entrar é um evento diferente de cair no meio: a primeira precisa ser reportada, a segunda deve reconectar em silêncio. Combine com maxRetries: 0 quando a primeira tentativa tiver que falhar rápido em vez de gastar o cronograma inteiro contra um servidor que não está lá.
send() é no-op quando o socket não está aberto
Se você chamar send() antes do status virar "open" (ou após um close), nada é enviado e o retorno é false. Sempre confira status === "open" (ou o boolean de retorno) antes de assumir que a mensagem saiu.
Ligue queueWhileClosed: true quando a ação não pode sumir: aí o send() bufferiza durante o backoff e devolve true, e a fila é despejada no open seguinte. A fila morre no close() — nada sobrevive a um fechamento explícito.
Contra o servidor Tempest, não ligue pingInterval
O tempest-fastapi-sdk manda o ping e quer o pong de volta. Um {"type":"ping"} vindo do cliente é frame desconhecido pra ele — um handler estrito recusa. Deixe pingInterval: 0 (default) e o respondToPing cuida do heartbeat.
Hook — useWebSocket
import { useState } from "react";
import { useWebSocket } from "tempest-react-sdk";
interface ChatEvent {
user: string;
text: string;
}
export function Chat({ enabled }: { enabled: boolean }) {
const [draft, setDraft] = useState("");
const [log, setLog] = useState<ChatEvent[]>([]);
const ws = useWebSocket<ChatEvent>(`${import.meta.env.VITE_WS_URL}/chat`, {
enabled,
pingInterval: 30_000,
onMessage: ({ data }) => setLog((prev) => [...prev, data]),
});
function sendMessage() {
const ok = ws.send(JSON.stringify({ user: "me", text: draft }));
if (ok) setDraft("");
}
return (
<div>
<span>Status: {ws.status}</span>
<ul>
{log.map((m, i) => (
<li key={i}>
<strong>{m.user}:</strong> {m.text}
</li>
))}
</ul>
<input value={draft} onChange={(e) => setDraft(e.target.value)} />
<button disabled={ws.status !== "open"} onClick={sendMessage}>
Enviar
</button>
{ws.status === "error" && <button onClick={ws.reconnect}>Reconectar</button>}
</div>
);
}
- O hook devolve
{ status, lastMessage, send, reconnect, setSilenceTimeout }. enabled: falsenão abre o socket; mudar a URL reabre. Todos os callbacks (onOpen,onMessage,onClose,onError,onReconnecting,onReconnected,onLost) passam por ref: arrow function inline é seguro, sempre roda a closure atual e nunca reabre o socket.- Opções que moldam a conexão (
protocols,maxRetries,initialBackoff,maxBackoff,jitter,handshakeTimeout,silenceTimeout,waitForOnline,pingInterval,queueWhileClosed) fazem parte do handshake — mudar uma reabre o socket com o valor novo, em vez de ser ignorada em silêncio.
lastMessage é foto, não fila
Cada frame vira um setState, então dois que cheguem no mesmo tick colapsam num render só e você enxerga apenas o último. Uma única ação no servidor costuma emitir vários frames em sequência. Para stream use onMessage, que dispara uma vez por frame; lastMessage serve pra renderizar estado atual.
sendé estável (useCallback) — pode ir em deps sem reabrir nada.- Cleanup automático no unmount (fechamento limpo, não tenta reconectar).
Frame que não é JSON
Por padrão os dois transportes fazem JSON.parse do frame. Quando o parse falha —
o servidor devolveu HTML de erro, um ping em texto puro, um proxy injetou algo —
o SDK entrega a string crua anunciada como o seu tipo. É o comportamento
histórico e ele continua, porque mudá-lo quebraria quem depende dele; o que mudou
é que ele parou de ser silencioso.
import { createWebSocket } from "tempest-react-sdk";
interface Evento {
id: string;
tipo: string;
}
const socket = createWebSocket<Evento>("wss://api.exemplo.com/eventos", {
onParseError: (erro, raw) => {
console.error("frame ilegível, descartado:", raw.slice(0, 120), erro);
},
onMessage: ({ data }) => {
console.log(data.id);
},
});
Com onParseError registrado, o frame quebrado não chega em onMessage — quem
pediu para ouvir a falha não pediu para também receber o frame. Sem ele, o frame é
entregue como antes e um build de desenvolvimento avisa uma vez por transporte
no console.
Por que o padrão antigo é uma armadilha
data tipado como Evento sendo na verdade uma string não explode no
parse — explode no primeiro data.id, longe dali, sem nada apontando para o
frame que causou. O aviso e o onParseError existem para o erro aparecer onde
ele acontece.
parser continua mandando
Passar parser desliga tudo isso: o resultado dele é entregue, porque
decodificar texto, binário em base64 ou um protocolo próprio é justamente o
propósito da opção. onParseError só entra em cena quando não há parser. A
única coisa que ainda olha o resultado do parser é o
schema, quando você passa os dois.
Validar o frame com schema
O tipo genérico (createWebSocket<Evento>) é uma promessa sobre o servidor
que o TypeScript não tem como cumprir: ele apaga em runtime. Um frame sem o
campo message chega em onMessage anunciado como Evento, com
message: undefined, e o app grava isso no IndexedDB e renderiza uma
notificação vazia — sem nenhum erro no caminho.
Passe schema e validar deixa de ser trabalho do app:
import { useWebSocket } from "tempest-react-sdk";
import { z } from "zod";
const frameSchema = z.object({
kind: z.string(),
body: z.string(),
});
type Frame = z.infer<typeof frameSchema>;
export function Sala() {
const { status } = useWebSocket<Frame>("wss://api.exemplo.com/sala", {
schema: frameSchema,
onValidationError: (issues, raw) => {
console.warn("frame fora do contrato, descartado:", issues, raw.slice(0, 120));
},
onMessage: ({ data }) => {
console.log(data.body);
},
});
return <p>Sala: {status}</p>;
}
O que a opção garante:
- sem
schema, nada muda — o caminho de hoje continua igual, fallback e aviso inclusive; - o frame que não casa não é entregue, a mesma regra que o
onParseErrorjá segue, eonValidationErrorrecebe osissues(pathpontilhado +message) mais o texto cru; - o valor entregue é a saída do schema, então
z.coerce,.default()e.transform()valem; parserdecodifica primeiro, e oschemavalida o que ele devolveu;- frame vazio é frame inválido:
JSON.parse("")lança, e comschemao texto cru vai para o schema, que o recusa — em vez de chegar como string vazia anunciada como o seu tipo.
zod, valibot, arktype — e o SDK não depende de nenhum
O schema aceita qualquer coisa com Standard Schema
(~standard, que zod >= 3.24, valibot e arktype implementam) ou com
.safeParse — a assinatura que todo usuário de zod já conhece, e que cobre
o zod 3.23 anterior ao ~standard. Nenhuma dessas libs entra como
dependência: a validação é chamada pela interface.
A validação tem de ser síncrona
O frame é decodificado dentro do handler de message e entregue de lá, então
não há onde await: um schema assíncrono entregaria os frames na ordem em que
as validações resolvessem. Esse caso é reportado em onValidationError,
não aguardado. Use z.object(...), não .refine(async ...).
Declare o schema fora do componente
O schema é lido quando o socket abre. Um schema construído inline é um objeto
novo a cada render — declare no módulo (ou memoize), e o que vale é o que existia
na última abertura.
O pong continua saindo
O ping do servidor ({"type":"ping"}) quase nunca casa com o schema do app —
e ele não deixa de ser respondido por isso. O heartbeat é contrato do
transporte com o servidor, não do app com o payload dele: o
tempest-fastapi-sdk fecha o socket com 4408 quando o pong não chega,
então a resposta é decidida do texto do frame, antes do schema. O ping em si
não chega ao seu onMessage quando o schema o recusa — o que é o certo, já
que você não o declarou.
Sinal que existe em produção, não só em dev
O aviso único é dev-only (passa por isDevBuild(), que responde certo sozinho
em Vite, webpack, Rspack e Parcel — medido) e mora no console de quem estiver
com o DevTools aberto. O onValidationError é do app: dispara em qualquer
build, e é dele que sai a métrica de "o backend mudou o contrato" — a que
chega no seu logger em produção, onde ninguém está olhando console.
Status
"idle" | "connecting" | "open" | "closing" | "closed" | "error" — error é terminal e chega junto com onLost: ou o cronograma esgotou ("exhausted"), ou o servidor recusou o cliente ("rejected"). Uma reconexão em curso passa por "connecting", não por "error".
Recap
createWebSocket(url, options)abre um WebSocket bidirecional; o controller expõesend,close,reconnect,setSilenceTimeout,openedestatus.send()retornafalse(no-op) quando o socket não está aberto — sempre cheque antes.- Três falhas não disparam evento nenhum e têm knob próprio: handshake pendurado (
handshakeTimeout), link morto em voo (silenceTimeout), rádio desligado (waitForOnline). - Reabrir depende do código de fechamento, não só de
wasClean:4400–4499é recusa (menos4408, que é o heartbeat e reabre);1000limpo é despedida. onReconnectingé estado discreto;onLosté o que merece UI e um botão de "tentar de novo".openedsepara "não consegui entrar" de "caiu no meio" — a primeira é erro, a segunda reconecta sozinha.pingInterval+pingPayloadmantêm a conexão viva.schemavalida cada frame antes de entregar — opongdo heartbeat continua saindo mesmo quando o schema recusa o ping.useWebSocketamarra tudo ao componente e expõestatus/lastMessage/send/reconnect/setSilenceTimeout.