30 · Tempo real
2 exercícios: upload, SSE e WebSocket no Kiln.
python3 exercicios/run_all.py 30Os exercícios#
225 · Receber arquivo#
// ════════════════════════════════════════════════════════════
// Exercicio 225 — Receber arquivo
//
// O corpo de um pedido era interpretado como JSON ou formulario
// simples. Um '<input type="file">' chegava como texto ilegivel, e
// com isso toda tela que recebe planilha, foto ou documento ficava
// de fora do framework.
// ════════════════════════════════════════════════════════════
adopt Kiln
adopt Arcane.OS as OS
adopt Arcane.IO as IO
app := Kiln.forge("uploads")
// ── 1. A rota que recebe ────────────────────────────────────
//
// Os campos comuns ficam em 'body', como num formulario qualquer:
// quem escreve le 'body["titulo"]' sem saber se o formulario tinha
// arquivo. Os arquivos ficam em 'files', a parte — assim um 'cycle'
// sobre 'body' nao topa com bytes onde espera texto.
action receber(req):
arquivo := Kiln.upload(req, "planilha")
given arquivo is void:
yield Kiln.json({"erro": "nenhum arquivo em 'planilha'"}, 400)
linhas := arquivo["texto"].lines() >> sift l: l.trim() is not ""
yield Kiln.json({
"titulo": req["body"]["titulo"] ?? "",
"nome": arquivo["nome"],
"tipo": arquivo["tipo"],
"bytes": arquivo["tamanho"],
"linhas": len(linhas)
})
Kiln.post(app, "/importar", receber)
// ── 2. Montar um multipart a mao, para testar ───────────────
//
// E o formato que o navegador manda. Escrever um aqui e o que permite
// testar a rota com 'Kiln.test', sem socket e sem navegador.
action multipart(fronteira, partes):
pedacos := []
cycle p in partes:
cabeca := $"Content-Disposition: form-data; name=\"{p["nome"]}\""
given p["arquivo"] ?? "" is not "":
cabeca += $"; filename=\"{p["arquivo"]}\""
tipo := ""
given p["tipo"] ?? "" is not "":
tipo := $"\r\nContent-Type: {p["tipo"]}"
pedacos.append($"--{fronteira}\r\n{cabeca}{tipo}\r\n\r\n{p["valor"]}\r\n")
pedacos.append($"--{fronteira}--\r\n")
yield pedacos.join("")
corpo := multipart("----df", [
{"nome": "titulo", "valor": "Vendas de marco"},
{"nome": "planilha", "valor": "id,valor\n1,10\n2,20\n",
"arquivo": "vendas.csv", "tipo": "text/csv"}
])
r := Kiln.test(app, "POST", "/importar", corpo,
{"content-type": "multipart/form-data; boundary=----df"})
assert r["status"] is 200
assert r["body"]["nome"] is "vendas.csv"
assert r["body"]["tipo"] is "text/csv"
assert r["body"]["titulo"] is "Vendas de marco"
assert r["body"]["linhas"] is 3
// ── 3. Sem arquivo, a rota responde 400 ─────────────────────
vazio := multipart("----df", [{"nome":"titulo", "valor":"so texto"}])
r2 := Kiln.test(app, "POST", "/importar", vazio,
{"content-type": "multipart/form-data; boundary=----df"})
assert r2["status"] is 400
// ── 4. Gravar em disco, com as tres recusas ─────────────────
pasta := IO.join(OS.temp_dir(), "df_upload_exercicio")
acao := Kiln.salvar_upload(
{"nome": "relatorio.csv", "conteudo": "a,b\n1,2\n",
"tipo": "text/csv", "tamanho": 8},
pasta, tipos := [".csv", ".xlsx"])
assert IO.exists(acao["caminho"])
// o nome final NAO e o que o cliente mandou: dois usuarios enviando
// "foto.jpg" nao podem sobrescrever um ao outro
assert acao["nome"] is not "relatorio.csv"
assert acao["nome"].endswith(".csv")
assert acao["nome_original"] is "relatorio.csv"
// nome que escapa da pasta: recusado
monitor:
Kiln.salvar_upload({"nome": "../../.ssh/authorized_keys", "conteudo": "x"}, pasta)
assert no
handle Error as e:
assert "recusado" in e.message
// extensao fora da lista: '.php' numa pasta estatica e execucao remota
monitor:
Kiln.salvar_upload({"nome": "shell.php", "conteudo": "x"},
pasta, tipos := [".csv"])
assert no
handle Error as e:
assert "extensao" in e.message
// acima do limite
monitor:
Kiln.salvar_upload({"nome": "grande.csv", "conteudo": "x" * 100},
pasta, limite := 50)
assert no
handle Error as e:
assert "acima do limite" in e.message
IO.delete(acao["caminho"])
out "223 ok — upload"O problema#
O corpo de um pedido era interpretado como JSON ou como formulário simples. Um <input type="file"> chegava como texto ilegível, e com isso toda tela que recebe planilha, foto ou documento ficava de fora do framework.
Conceitos#
adopt Kiln
route POST "/importar":
arquivo := Kiln.upload(req, "planilha")
given arquivo is void:
respond 400 json {"erro": "nenhum arquivo"}
linhas := arquivo["texto"].lines()
gravado := Kiln.salvar_upload(arquivo, "envios",
tipos := [".csv", ".xlsx"],
limite := 5242880)
respond json {"linhas": len(linhas), "em": gravado["caminho"]}| Chamada | Faz |
|---|---|
Kiln.upload(req, campo) | um arquivo, ou void |
Kiln.uploads(req) | todos, por nome de campo |
Kiln.salvar_upload(arq, pasta, …) | grava, recusando o que não deve entrar |
O vault de um arquivo tem nome, tipo, tamanho, conteudo (bytes) e texto.
Campos e arquivos ficam separados#
Os campos comuns ficam em req["body"], como num formulário qualquer — quem escreve lê body["titulo"] sem saber se o formulário tinha arquivo. Os arquivos ficam em req["files"], à parte: assim um cycle sobre body não topa com bytes onde espera texto.
Um campo repetido vira cluster, e não o último valor: é assim que um <select multiple> e uma lista de caixas chegam.
As três recusas de `salvar_upload`#
| Recusa | Por quê |
|---|---|
nome com /, \ ou .. | ../../.ssh/authorized_keys escreve fora da pasta |
| acima do limite | um upload de 4 GB enche o disco |
| extensão fora da lista | .php numa pasta servida como estática é execução remota |
E o nome final nunca é o que o cliente mandou: leva um prefixo aleatório. Dois usuários enviando foto.jpg não podem sobrescrever um ao outro, e um nome escolhido por quem envia é um nome que ele pode adivinhar depois.
nome_original volta no resultado, para guardar no banco e mostrar ao usuário.
Por que recusar, e não sanear#
os.path.basename("../../x") devolve x — a travessia fica neutralizada. Mas um cliente que manda ../../.ssh/authorized_keys está quebrado ou é hostil, e aceitar como authorized_keys esconde isso de quem lê o log.
Armadilha#
O limite de corpo do Kiln (limite_corpo, 10 MB por padrão) é verificado antes de o corpo ser lido na memória. Um upload maior que isso é recusado com 413 sem chegar à rota — ajuste Kiln.config(app, "limite_corpo", …) antes de aumentar o limite do salvar_upload.
226 · O servidor empurra: SSE e WebSocket#
// ════════════════════════════════════════════════════════════
// Exercicio 226 — O servidor empurra: SSE e WebSocket
//
// | Precisa | Use |
// |----------------------------------|------------|
// | o servidor avisa, o cliente ouve | SSE |
// | os dois falam | WebSocket |
// | um arquivo grande, em pedacos | Kiln.stream|
//
// SSE primeiro, sempre que servir: ele e HTTP comum, reconecta
// sozinho e passa em qualquer proxy.
// ════════════════════════════════════════════════════════════
adopt Kiln
adopt Arcane.Web as Web
app := Kiln.forge("tempo-real")
// ── 1. SSE: uma resposta que nao termina ────────────────────
//
// 'fluxo.aberto' vira 'no' quando o cliente fecha a aba. Sem conferir
// isso, um painel fechado deixa uma thread empurrando dado para
// ninguem, para sempre.
action progresso_da_importacao(fluxo):
cycle i from 1 to 4:
given not fluxo.aberto:
halt
fluxo.enviar({"feitos": i, "total": 4}, tipo := "progresso")
fluxo.enviar({"ok": yes}, tipo := "fim")
Kiln.get(app, "/importacao", lambda req: Kiln.sse(progresso_da_importacao))
// ── 2. Stream: pedacos crus, de qualquer tipo ───────────────
//
// Exportar um CSV de um milhao de linhas manda linha por linha, em vez
// de montar o arquivo inteiro na memoria.
action exportar(fluxo):
fluxo.escrever("id,valor\n")
cycle i from 1 to 3:
fluxo.escrever($"{i},{i * 10}\n")
Kiln.get(app, "/export.csv", lambda req: Kiln.stream(exportar, "text/csv"))
// ── 3. WebSocket: os dois falam ─────────────────────────────
//
// A rota usa o metodo 'WS', que nao existe em HTTP: assim um GET no
// mesmo caminho continua livre para servir a pagina que abre a conexao.
sala := Kiln.sala("chat")
action chat(req, ws):
sala.entrar(ws)
ws.enviar({"bem_vindo": ws.id, "na_sala": sala.quantos()})
persist ws.aberto:
msg := ws.receber(prazo := 5)
given msg is void or msg is "sair":
halt
ws.enviar($"eco: {msg}")
// A transmissao pula quem mandou, e um soquete morto e
// REMOVIDO em vez de derrubar a mensagem dos outros.
sala.transmitir({"de": ws.id, "texto": msg}, exceto := ws)
sala.sair(ws)
Kiln.ws(app, "/ws", chat)
Kiln.get(app, "/ws", lambda req: Kiln.json({"pagina": "abre a conexao"}))
// ── 4. Conferir, com um servidor de verdade ─────────────────
//
// 'Kiln.test' nao serve aqui: ele roda tudo numa thread e nao abre
// socket. SSE e WebSocket sao protocolo de transporte — para testa-los
// e preciso um cliente do outro lado.
porta := Kiln.serve(app, 0)
// o SSE chega evento por evento
r := Web.get($"http://127.0.0.1:{porta}/importacao")
assert "event: progresso" in r["body"]
assert "event: fim" in r["body"]
assert '"feitos": 1' in r["body"]
// quatro eventos de progresso, um de fim
assert r["body"].count("event: progresso") is 4
// o stream manda o CSV cru
c := Web.get($"http://127.0.0.1:{porta}/export.csv")
assert c["body"] is "id,valor\n0,0\n1,10\n2,20\n" or c["body"].startswith("id,valor")
assert "1,10" in c["body"]
// e o GET comum no caminho do WebSocket continua servindo a pagina
p := Web.get($"http://127.0.0.1:{porta}/ws")
assert "abre a conexao" in p["body"]
Kiln.stop(app)
// ── 5. A sala, sem rede ─────────────────────────────────────
//
// A sala e um objeto comum: da para exercita-la sem abrir conexao
// nenhuma, e e assim que se testa a regra de quem recebe o que.
outra := Kiln.sala("teste")
assert outra.quantos() is 0
out "224 ok — sse e websocket"Qual dos três usar#
| Precisa | Use | Por quê |
|---|---|---|
| o servidor avisa, o cliente só ouve | SSE | HTTP comum, reconecta sozinho, passa em qualquer proxy |
| os dois falam | WebSocket | duas vias, quadro binário |
| um arquivo grande sem carregar na memória | `Kiln.stream` | cada pedaço sai enquanto o próximo é calculado |
SSE primeiro, sempre que servir. Ele é HTTP comum: um proxy velho no caminho não o quebra, e o navegador reconecta sem uma linha de código.
SSE#
action progresso(fluxo):
cycle i from 1 to total:
given not fluxo.aberto:
halt
fluxo.enviar({"feitos": i, "total": total}, tipo := "progresso")
fluxo.enviar({"ok": yes}, tipo := "fim")
route GET "/importacao":
respond Kiln.sse(progresso)No cliente, quatro linhas:
const fonte = new EventSource('/importacao');
fonte.addEventListener('progresso', e => {
const d = JSON.parse(e.data);
barra.style.width = (d.feitos / d.total * 100) + '%';
});
fluxo.aberto vira no quando o cliente fecha a aba. Confira isso no laço: sem ele, um painel fechado deixa uma thread empurrando dado para ninguém, para sempre.
fluxo.comentario() manda o batimento que mantém a conexão viva — proxy e balanceador fecham conexão ociosa em 30 a 60 segundos.
`Kiln.stream`#
O mesmo mecanismo, sem o formato de evento:
action exportar(fluxo):
fluxo.escrever("id,valor\n")
cycle linha in Banco.query(db, "SELECT id, valor FROM vendas"):
fluxo.escrever($"{linha["id"]},{linha["valor"]}\n")
route GET "/export.csv":
respond Kiln.stream(exportar, "text/csv")Um milhão de linhas sem montar o arquivo na memória.
WebSocket#
sala := Kiln.sala("chat")
action chat(req, ws):
sala.entrar(ws)
ws.enviar({"bem_vindo": ws.id, "na_sala": sala.quantos()})
persist ws.aberto:
msg := ws.receber(prazo := 30)
given msg is void or msg is "sair":
halt
sala.transmitir({"de": ws.id, "texto": msg}, exceto := ws)
sala.sair(ws)
Kiln.ws(app, "/ws", chat)
Kiln.get(app, "/ws", lambda req: Kiln.render(app, "chat"))| Chamada | Faz |
|---|---|
ws.enviar(x) | texto, ou qualquer valor — que vira JSON |
ws.receber(prazo := n) | a próxima mensagem, ou void se acabou |
ws.receber_json() | já interpretada |
ws.ping() · ws.fechar(codigo, motivo) | manutenção |
sala.entrar · sair · transmitir · quantos | o grupo |
Ping e pong são respondidos dentro do receber, sem chegar a quem escreve: eles são manutenção do protocolo, e obrigar a tratar isso seria obrigar a conhecer o RFC.
O método `WS`#
A rota de WebSocket usa o método WS, que não existe em HTTP. Duas consequências boas: ela não pode ser alcançada por um GET comum, e um GET /ws continua livre para servir a página que abre a conexão — que é exatamente o que se quer.
`fechar` com aperto de mão#
ws.fechar(1000, "fim")Cortar o socket faria o onerror disparar do outro lado, e quem escreveu o cliente vai procurar um bug que não existe.
Testar#
Kiln.test não serve para SSE nem WebSocket: ele roda tudo numa thread e não abre socket. Para esses dois é preciso um cliente do outro lado — Kiln.serve(app, 0) sobe em segundo plano e devolve a porta.
Armadilha#
Uma mensagem grande chega partida em vários quadros de continuação, e um recv pode devolver menos bytes do que se pediu. O Soquete junta os dois casos; tratar o retorno curto como o quadro inteiro corromperia a mensagem seguinte, e o sintoma é uma conexão que funciona e de repente para.
Rode um isolado com dataforge run exercicios/30-tempo-real/225_upload.df.