Ir para o conteúdo

Contrato de fronteira

O contrato de fronteira (wire format) é o acordo entre o Python (o reconciliador, vindo do core) e o cliente JS (que muta o DOM). Ele é o mesmo nos três transportes — pyodide.ffi (Modo A), WebSocket e SSE (Modo B). Só o envelope muda; nunca o shape dos dados. 🤝

Esta página é o resumo didático

O documento canônico, pinado por golden fixtures derivadas do core real, vive junto ao código: docs/contract.md. Qualquer agente que trabalhe no cliente ou nos transportes programa contra ele e contra as fixtures. Aqui damos a visão geral; o link tem cada campo.

Os quatro cruzamentos

A tipagem "vaza" pela fronteira em quatro pontos — análogo ao request/response do FastAPI:

  • 1. IR → cliente


    A árvore de Node serializada e a lista de patches do diff.

  • 2. Evento → handler


    O payload do click/input que sobe e é validado (Pydantic) antes de entrar no Python.

  • 3. Style → CSS


    O objeto Style tipado que o cliente traduz para CSS.

  • 4. Chamada nativa


    Web APIs expostas como awaitables tipados (native_call/native_result).

1. Node — a IR serializada

Cada nó da árvore tem o mesmo shape:

{
  "type": "Column",
  "key": "label",
  "props": { "style": null },
  "children": []
}
  • type — o nome do widget (Column, Row, Text, Button, Container, …).
  • key — chave estável para reconciliação (pode ser null).
  • props — props do widget, incluindo "style" (objeto Style ou null).
  • children — lista de Nodes filhos.

Handlers não atravessam como função

O core serializa uma referência; o evento volta com a key do widget. É o lado Python que resolve qual handler chamar — o cliente nunca executa lógica de app.

2. Os 5 patches

O reconciliador faz diff(old, new) e emite uma lista. path endereça o nó-alvo por índices ([] = raiz, [0] = primeiro filho).

Tipo Distingue-se por Semântica
Update set_props Aplica props e remove unset_props.
Insert node + index Insere um filho na posição.
Remove só index Remove o filho na posição.
Reorder order Reordena os filhos.
Replace node sem index Troca o nó inteiro.

3. Style

props.style é um objeto Style (ou null). Color é {r,g,b,a} (r/g/b 0–255, a 0–1) → CSS rgba(...). Edge é {top,right,bottom,left} em px.

{
  "direction": "column",
  "gap": 8.0,
  "padding": { "top": 16, "right": 16, "bottom": 16, "left": 16 },
  "background": { "r": 255, "g": 255, "b": 255, "a": 1.0 },
  "color": { "r": 17, "g": 17, "b": 17, "a": 1.0 },
  "width": 320.0
}

Style → CSS é quase identidade

O Style foi desenhado copiando o vocabulário do CSS, então a tradução é direta e vive no cliente (client/style.js) — um só tradutor para os dois modos.

4. Evento (cliente → Python)

{ "type": "click", "key": "inc", "payload": {} }

O lado Python resolve a key → handler do nó na árvore atual, valida o payload com Pydantic e invoca o handler (sync ou async).

Enquadramento por transporte

O shape de Node/Patch/Evento não muda entre transportes; muda só o envelope:

Chamada de função em-processo via pyodide.ffi. Sem rede e sem envelope: o lote de patches cruza como texto JSON (uma string atravessa a FFI sem conversão de proxy), e quem decodifica é o transporte (client/transport-wasm.js). Eventos voltam por callback.

Cada mensagem WS é um JSON com kind:

{ "kind": "patches", "data": [ /* Patch... */ ] }   // servidor → cliente
{ "kind": "event",   "data": { /* Evento */ } }      // cliente → servidor

O servidor responde text/event-stream. Cada tick é um evento SSE cujo data: é o JSON da mesma lista de patches. Eventos sobem por HTTP POST (corpo = Evento). Reconnect usa Last-Event-ID.

Todo lote é texto, e nem todo float vira texto

Os três transportes entregam texto ao cliente, então a fronteira real é a gramática do JSON — e ela não tem token para nan, inf nem -inf. O encoder do Python escreve NaN/Infinity por default, que nenhum JSON.parse aceita: o lote inteiro morria dentro da decodificação, antes do transporte, do renderizador e de qualquer diagnóstico.

Por isso o tempestweb codifica por um lugar só, encode_wire, que recusa o valor em vez de escrevê-lo, nomeando o campo:

tempestweb: cannot encode the wire payload — nan at data[1].style.width.
JSON has no token for nan/inf, so the client would reject the whole batch
while decoding it. Guard the value where it enters the tree (a float() over
a backend field, a division by zero).

Isso acontece com dado de backend mais do que parece: uma métrica que volta como a string "NaN" e passa por float(...), ou uma divisão por zero, caindo num campo numérico sem bound como Style.width. Trate na origem — value if isfinite(value) else 0.0 — porque o widget aceita o número e o fio não.

Do outro lado, um frame que mesmo assim não decodifique não some calado: o transporte reporta a perda no console e pede um resync (não um por frame — o mesmo payload voltaria igual).

A chamada nativa (Modo B — proxy)

O 4º cruzamento. No Modo A uma capacidade native/ chama a Web API direto no browser. No Modo B ela é proxiada por um round-trip:

// servidor → cliente: pedido de capacidade nativa
{ "kind": "native_call", "call_id": "c1", "capability": "geolocation.get", "args": {} }

// cliente → servidor: resultado tipado (ou erro)
{ "kind": "native_result", "call_id": "c1", "ok": true,  "value": { "lat": -23.5, "lon": -46.6 } }
{ "kind": "native_result", "call_id": "c1", "ok": false, "error": "PermissionDenied" }
  • call_id correlaciona pedido ↔ resultado (várias chamadas podem estar em voo).
  • capability é o nome estável (geolocation.get, clipboard.read, …).
  • O lado Python expõe isso como awaitable tipado — ver Capacidades.

A API Python é idêntica nos dois modos

No Modo A o mesmo await geolocation.get() resolve em-processo; no Modo B ele dispara o round-trip native_call/native_result. Só o caminho muda — por isso a assinatura tipada mora no contrato, não no transporte.

Recap

  • O contrato é o mesmo nos três transportes; só o envelope difere.
  • Quatro cruzamentos: IR → cliente, Evento → handler, Style → CSS, chamada nativa.
  • Os shapes são fixados por golden fixtures derivadas do core real.
  • Float não-finito não atravessa: encode_wire recusa e nomeia o campo.

Para cada campo, leia o docs/contract.md canônico. Para ver o contrato em ação, faça o Tutorial. 🚀