Deploy em produção¶
O que você vai aprender
Como colocar cada modo em produção. Os modos estáticos (A/WASM e C/transpile) são só arquivos — sobem em qualquer CDN. O Modo B (servidor) é um host FastAPI que precisa de reverse-proxy, TLS e (para escalar) sticky-sessions.
Modos estáticos (A / C) — CDN¶
tempestweb build --mode wasm ou --mode transpile gera um diretório estático.
Sirva-o em qualquer host de arquivos (Netlify, Vercel, S3+CloudFront, GitHub
Pages, nginx). Sem servidor, sem estado — só CDN + cache. O service worker
cuida do offline.
Modo B (servidor) — FastAPI¶
O host serve /ws, /sse, /sse/{id} e /health. Antes de expor
publicamente, endureça-o (ver Segurança):
create_app(make_state, view, security=SecurityConfig(
authenticate=jwt_authenticator(os.environ["JWT_KEY"]),
allowed_origins=["https://app.exemplo.com"],
max_connections=1000,
security_headers=True,
hsts=True,
))
Infra que bloqueia WebSocket? Troque o shell por SSE¶
O artefato de --mode server embarca os dois clientes de Modo B —
static/transport-ws.js (o que o index.html gerado monta) e
static/transport-sse.js. Se o seu proxy/CDN não deixa o WebSocket passar,
substitua o index.html do artefato; o mesmo host já responde em /sse e
/sse/{id}, e nada do lado Python muda:
<script type="module">
import { mount } from "./static/tempestweb.js";
import { createSSETransport } from "./static/transport-sse.js";
const transport = createSSETransport({ session: crypto.randomUUID() });
mount(document.getElementById("app"), transport);
</script>
O session é seu
A sessão do SSE é keyed pelo id que o cliente escolhe — os dois canais
(GET /sse?session=<id> e POST /sse/<id>) derivam dele. crypto.randomUUID()
dá uma por aba; guardá-lo no sessionStorage faz o reload reaproveitar a
mesma sessão. Com mais de uma réplica, mantenha as sticky-sessions (o
nginx.conf gerado já usa ip_hash) ou configure o roteador Redis.
Gere os arquivos de deploy (tempestweb deploy)¶
Em vez de escrever a config do nginx à mão, gere-a pro seu projeto:
Escreve em deploy/: nginx.conf (parametrizado pela porta do
tempestweb.toml, com upgrade WS, X-Forwarded-*, timeouts de streaming,
ip_hash e — com --tls — bloco 443 + redirect HTTP→HTTPS), Dockerfile
(+ HEALTHCHECK), docker-compose.yml e DEPLOY.md (guia). Flags:
--out, --server-name, --tls, --replicas, --no-sticky, --force.
Docker + reverse-proxy (referência)¶
Os mesmos arquivos, estáticos, também vivem em
examples/deploy/:
Dockerfile—python:3.12-slim+tempestweb[server], rodatempestweb run --mode server --host 0.0.0.0, comHEALTHCHECKem/health.nginx.conf— upgrade de WebSocket,Origin/X-Forwarded-*preservados, timeouts longos +proxy_buffering offpara WS/SSE, eip_hash(sticky).docker-compose.yml— app + nginx (TLS).
Escala horizontal (S4)¶
WebSocket é auto-contido (uma conexão duplex numa réplica) → escala sem
sticky. O SSE é a exceção: o GET (stream) e o POST (eventos) precisam
cair na mesma réplica. Duas opções:
- Sticky sessions (padrão) —
ip_hashno nginx fixa o cliente.tempestweb deployjá emite isso. - Backend Redis (dispensa sticky) — roteie o inbound do SSE por Redis pub/sub:
from tempestweb.server import create_app, RedisSessionRouter
app = create_app(make_state, view,
sse_backend=RedisSessionRouter.from_url("redis://redis:6379"))
Aí gere o nginx sem ip_hash: tempestweb deploy --no-sticky (round-robin).
Requer o extra [cache] (redis).
Não use --workers > 1 sem sticky
Cada worker uvicorn tem seu próprio registro de sessões em memória. Rode 1 worker por container e escale com réplicas atrás de um proxy sticky.
Health checks¶
GET /health (sem auth) responde {"status":"ok","sessions":N,"ready":bool}.
ready vira false quando max_connections é atingido — use no readiness do
balanceador para drenar uma instância cheia.
Métricas (S8)¶
create_app(..., metrics=True) monta GET /metrics no formato Prometheus:
tempestweb_sessions_live (gauge), tempestweb_sessions_opened_total e
tempestweb_connections_rejected_total (counters), e tempestweb_sessions_max
quando há cap. Aponte seu scraper pra lá.
Handler lento: spawn primeiro, concurrent_dispatch depois¶
Cada sessão despacha um evento por vez. É o que garante que duas teclas digitadas rápido cheguem na ordem — e é o que faz um handler demorado congelar aquela conexão inteira: nenhum outro botão daquele usuário responde enquanto ele roda.
Na esmagadora maioria dos casos a resposta não é mudar o dispatch, é tirar o
trabalho do handler com
tempestweb.runtime.spawn.
Ele não muda semântica nenhuma: a ordem dos eventos continua a mesma, e a task
morre junto com a conexão.
Quando você realmente quer handlers de widgets diferentes rodando ao mesmo tempo:
Cada evento vira sua própria task. Eventos do mesmo key continuam em ordem
de chegada (há um lock por widget), então digitação não embaralha; handlers de
widgets diferentes se sobrepõem. Um handler que levanta exceção nesse modo é
logado e descartado em vez de derrubar a conexão.
O que a opção exige de você
Com concurrent_dispatch=True dois handlers podem mutar o estado ao mesmo
tempo. O app precisa estar escrito para isso — um set_state que lê o
estado, calcula e grava deixa de ser atômico em relação a outro handler. Por
isso vem desligada por padrão.
Vale também ler o efeito no limite de carga: ligada, cada envelope aceito
vira uma task, e max_events_per_minute passa a ser o teto de tasks
concorrentes por IP — veja
Segurança → S2.
Recap¶
- A/C:
build→ publique o diretório estático num CDN. Fim. - B: endureça com
SecurityConfig, rode atrás de nginx (TLS + upgrade WS), escale com réplicas sticky (1 worker cada), monitore/health. - Handler lento:
spawnresolve sem mudar semântica;concurrent_dispatch=Truesó quando você quer sobreposição de verdade — e aí configuremax_events_per_minute.