Capacidades nativas¶
As capacidades (native/) são adaptadores de Web API expostos como
awaitables tipados em Python. Você escreve await geolocation.get() e recebe
um Position tipado — sem tocar em JavaScript. 📡
Trilho N — a superfície nativa
Esta camada é o Trilho N do roadmap (fases N0–N4, detalhadas no
plano de design).
As capacidades funcionam nos três modos de execução — cada uma resolve o
seu backend conforme o --mode.
Uma API Python, três caminhos¶
O princípio central: a API Python é sempre a mesma; o --mode escolhe como a
chamada chega na Web API, não o seu código.
A chamada vai direto na Web API via pyodide.ffi, dentro do browser. Sem
rede.
A chamada é proxiada por um round-trip: o servidor emite um pedido nativo pelo transporte (WS/SSE), o cliente executa a Web API e devolve o resultado tipado.
O contrato é o mesmo
Nos Modos A e B o envelope native_call/native_result está no
contrato de fronteira. No
Modo C não há envelope — a chamada é transcrita — mas a assinatura tipada é
idêntica. Você escreve uma linha; o modo decide o mecanismo.
No Modo B a ponte do cliente já vem ligada
Você não precisa escrever nenhuma linha de JavaScript para as capacidades
funcionarem no Modo B. Ao receber um native_call, o transporte
(transport-ws.js · transport-sse.js) executa a capacidade pelo mesmo
registro que o Modo A usa (dispatch(), em native/index.js) e devolve o
native_result — inclusive o mesmo código de erro. Qualquer shell serve,
o gerado por tempestweb build --mode server inclusive.
Só se você quiser interceptar as chamadas — mockar em teste, exigir uma
confirmação do usuário, roteá-las para outro lugar — passe onNativeCall
ao criar o transporte; a opção substitui a ponte padrão:
As capacidades¶
| Capacidade | API Python | Espelha (React SDK) |
|---|---|---|
http (N0) |
await http.request(...), upload, poll, idempotency_key |
createApiClient/retry |
audio (N1) |
await audio.play(src, volume=...), audio.stop() |
playAudio/useAudio |
share (N2) |
await share(title=..., url=...) → ShareResult |
share/isShareSupported |
geolocation (N3) |
await geolocation.get() → Position |
— |
clipboard (N3) |
await clipboard.read() / clipboard.write(text) |
— |
storage (N3) |
put/get/list (sobre IndexedDB) |
createOfflineStore |
camera (N4) |
await camera.capture() → bytes/Blob |
— |
A superfície completa — Trilho T
A tabela acima é o núcleo histórico (Trilho N). O Trilho T expandiu a ponte
para dezenas de grupos — vibração, badge, wake lock, tela cheia, rede,
sensores, bluetooth, USB, MIDI e muito mais, agrupados por tier (universal /
muito usado / só-Chromium). O catálogo completo, com um trecho executável por
grupo, está na Referência de capacidades nativas. As
capacidades de stream (consumidas com async for) têm um tutorial próprio:
o Canal de eventos nativo. 🚀
Migração: o que a ≤0.122.0 gravou ficou no localStorage
Até a 0.122.0 o native.storage dos Modos A e B caía no localStorage —
era o defeito que a 0.123.0 corrigiu. Da 0.123.0 em diante a leitura vai
ao IndexedDB, então esse conteúdo antigo fica órfão: não é apagado, mas
ninguém mais o lê, e a sua app volta a ver um store vazio.
App nova não faz nada. App já publicada migra uma vez, na página do
artefato, antes do boot — lendo o localStorage e reescrevendo por
storage.put:
<script type="module">
import { dispatch } from "./client/native/index.js";
const MARCA = "tw.storage.migrated.v1";
const LEGADO = ["notes", "draft", "cache"]; // as chaves da SUA app
if (!localStorage.getItem(MARCA)) {
let tudo_ok = true;
for (const name of LEGADO) {
const content = localStorage.getItem(name);
if (content === null) continue;
const escrita = await dispatch({
kind: "native_call",
call_id: `migrate-${name}`,
capability: "storage.put",
args: { name, content },
});
tudo_ok = tudo_ok && escrita.ok;
}
if (tudo_ok) localStorage.setItem(MARCA, "1");
}
</script>
Liste as chaves da sua app: Object.keys(localStorage) varre também o que
não é seu. E não apague o original — num perfil onde o IndexedDB não abre a
capacidade continua gravando no próprio localStorage, o put reescreve a
mesma chave, e um removeItem depois apagaria o dado que você acabou de
salvar. A marca é o que evita reprocessar a cada boot.
Exemplo: HTTP tipado com retry¶
O native.http (N0) é a base do replay offline. Uma requisição com retry e
idempotency key:
from tempestweb.native import http
from tempestweb.native.http import RetryOptions
async def submit_order(payload: dict[str, object]) -> dict[str, object]:
"""Submit an order with retry and an idempotency key.
Args:
payload: The order body to POST.
Returns:
The decoded JSON response.
"""
key = http.generate_idempotency_key()
response = await http.request(
"POST",
"/api/orders",
json=payload,
retry=RetryOptions(attempts=3, base_delay=0.5),
idempotency_key=key,
)
return dict(response.json_body)
Os botões do RetryOptions
attempts conta a primeira tentativa (1 desliga o retry), base_delay
é a espera antes do primeiro retry, factor multiplica a espera a cada
falha, max_delay limita cada espera e retry_statuses diz quais status
merecem nova tentativa. Kwarg que o modelo não declara é recusado com
ValidationError nomeando o campo — a mesma resposta que um widget dá, para
nome errado não passar por configuração que não aconteceu.
O corpo decodificado é json_body
HttpResponse é um modelo Pydantic com status, ok, headers, text e
json_body. O corpo já parseado é o campo json_body — response.json()
é o serializador do Pydantic (devolve string do modelo inteiro), não o corpo.
Idempotency key evita duplicar efeito
Se o retry reentrega a mesma requisição, a idempotency_key garante que o
servidor aplica o efeito uma só vez. Essa é a peça que torna a fila offline
do Trilho P segura.
Capacidade lenta dentro do handler trava a sessão
A sessão despacha um evento por vez. Um http.request com
RetryOptions(attempts=3, base_delay=0.5) que bate em timeout gasta segundos —
e durante todos eles nenhum outro botão daquele usuário responde. O mesmo
vale para file.pick num arquivo grande, upload, e qualquer onnx.*.
Quando a chamada pode demorar, tire-a do handler com
spawn e pinte um
estado de "carregando" antes.
Exemplo: geolocalização¶
from tempestweb.native import geolocation
async def center_map(app: object) -> None:
"""Read the device position and update the app state.
Args:
app: The running app handle.
"""
pos = await geolocation.get() # Position(lat=..., lon=...)
app.set_state(lambda s: setattr(s, "center", (pos.lat, pos.lon)))
Permissão é caminho normal, não exceção fatal
Geolocation, clipboard e camera exigem permissão e contexto seguro (HTTPS). Trate a negação como um fluxo normal — uma exceção tipada que sua UI apresenta com elegância, não um crash.
Câmera no Modo B (sempre no cliente)¶
A captura de câmera sempre acontece no cliente, mesmo no Modo B. Quando você
chama await camera.capture() "no servidor", o round-trip dispara a captura no
browser e a foto volta tipada (base64 ou referência de blob).
from tempestweb.native import camera
async def take_photo() -> bytes:
"""Capture a photo from the device camera.
Returns:
The captured image bytes.
"""
blob = await camera.capture() # captura no cliente; volta tipado no Modo B
return blob.data
Comprima antes de subir
No Modo B a foto atravessa a rede no round-trip. Comprima no cliente antes de devolver para manter o payload pequeno.
Preview ao vivo e leitor de QR (widgets)¶
camera.capture() é uma foto: abre a câmera, pega um frame, fecha. Quando o
app precisa da câmera ligada — um preview na tela, um leitor de código —
o widget é o caminho, porque ele mantém o stream enquanto está montado e o fecha
quando sai (câmera aberta é luz acesa no celular de alguém).
from tempest_core import App, Widget
from tempest_core import CameraFrameEvent, QrScanEvent
from tempest_core import CameraPreview, QrScanner
def view(app: App[State]) -> Widget:
"""Show the camera and read codes from it."""
def framed(event: CameraFrameEvent) -> None:
"""Recebe um frame amostrado do preview."""
app.set_state(lambda state: setattr(state, "last", f"{event.width}x{event.height}"))
def scanned(event: QrScanEvent) -> None:
app.set_state(lambda state: setattr(state, "code", event.data))
return Column(
key="root",
children=[
CameraPreview(
key="preview",
facing="back",
frame_interval_ms=500,
on_frame=framed,
),
QrScanner(key="scanner", on_scan=scanned),
],
)
- No browser,
event.dataé um JPEG em base64 — não o buffer RGB cru que a docstring do core descreve (essa é a forma do Android/tempestroid). Medido num frame 2560×1080: 22.772 caracteres de base64; o RGB cru do mesmo frame daria ~11 MB. Decodifique combase64.b64decode(event.data)e um leitor de JPEG (pillow, no Modo A), ou passe os bytes direto paravision.event.width/heightsão do frame, erotationé sempre0no browser: o<video>já entrega a imagem na orientação certa. frame_interval_msé o seu orçamento de rede. No Modo B cada frame é um round-trip com a imagem dentro; 500ms é uma escolha, 30fps é um plano de saturar a conexão. No Modo A o custo é local, mas ainda é CPU por frame. Medido em Chrome real comframe_interval_ms=250: gaps de 242–264 ms (mediana 249) ao longo de 12 frames.facingvira ofacingModedogetUserMedia:back→environment,front→user.- Um código lido não é reportado a cada tick. Ele fica no enquadramento por vários frames; o cliente reporta a mudança, não a presença.
- Contexto seguro obrigatório.
localhostconta; um deploy precisa de HTTPS, ou ogetUserMedianão existe.
QrScanner depende do BarcodeDetector do browser
A decodificação é a do próprio navegador — hoje Chrome/Android. Onde ela não
existe, o widget mostra a câmera e avisa no console, sem decodificar: este
cliente não embute dependência de runtime, então não há decoder de reserva.
Se você precisa de cobertura ampla, use o CameraPreview e decodifique os
frames você mesmo (é para isso que on_frame entrega os bytes).
Inferência ONNX no browser (native.onnx)¶
onnxruntime (a extensão C do CPython) não tem wheel Pyodide — Python no
browser não roda um grafo ONNX em-processo. A capacidade onnx cobre o vão:
o grafo roda em JavaScript via onnxruntime-web (build WASM), dirigido pela
mesma costura native_call. Você faz o pré/pós-processamento em Python (numpy +
pillow, ambos no Pyodide) e atravessa só a execução do tensor.
from tempestweb.native import onnx
from tempestweb.native.onnx import Tensor
async def detect(input_b64: str) -> dict[str, Tensor]:
"""Run a YOLO ONNX model loaded same-origin from the artifact."""
model = await onnx.load("./models/detect.onnx") # compila a sessão (cache no JS)
feeds = {model.input_name: Tensor(data_base64=input_b64, dims=[1, 3, 640, 640])}
return await onnx.run(model.session_id, feeds) # → {nome: Tensor}
Carregue o onnxruntime-web por [wasm].scripts e vendore-o (e os .onnx) por
[wasm].assets, para o service worker precachear tudo e a inferência rodar
offline. O provedor wasm é forçado (o build web não tem alguns kernels sob
WebGPU). Tensores cruzam como bytes base64 + shape + dtype — a capacidade é
numpy-free; o lado Python (que tem numpy) serializa.
Salvar arquivo gerado (native.file)¶
O browser não tem escrita síncrona de arquivo. file.save entrega um blob gerado
em Python por navigator.share({files}) (quando a plataforma aceita) ou por
download via <a download> (desktop), reportando qual caminho rodou.
from tempestweb.native import file
async def export_zip(zip_bytes: bytes) -> None:
"""Share or download a generated ZIP."""
await file.save("historico.zip", zip_bytes, mime_type="application/zip")
Instalação do PWA (native.install)¶
Exponha o fluxo de instalação do PWA ao Python: saber se o app é instalável
(beforeinstallprompt capturado) ou já instalado, e disparar o prompt após um
gesto real do usuário.
from tempestweb.native import install
async def on_install_tap() -> None:
"""Fire the native install prompt from a button handler."""
outcome = await install.prompt() # "accepted" | "dismissed" | "unavailable"
async def maybe_show_install_button() -> bool:
"""Whether to show an Install button."""
state = await install.state() # InstallState(can_install, installed)
return state.can_install and not state.installed
client/native/install.js envolve o controlador soft de
client/pwa/install-prompt.js (suprime o mini-infobar e guarda o evento).
Extras de build do Modo A ([wasm])¶
Capacidades que dependem de pacotes Pyodide extras, módulos Python próprios,
assets estáticos ou libs JS declaram-se no tempestweb.toml:
[wasm]
packages = ["numpy", "pillow"] # loadPackage além do pydantic do core
modules = ["famacha", "ort_vision_sdk"] # pacotes Python bundlados junto do app.py
assets = ["models/*.onnx", "vendor/ort/*"] # copiados (path preservado) + precache
scripts = ["./vendor/ort/ort.wasm.min.js"] # <script> injetado antes do bootstrap
De onde vem cada module
Cada nome em modules é resolvido em duas etapas, nesta ordem:
- Cópia vendida ao lado do
app.py(<projeto>/<module>/), se existir — o comportamento histórico, em que uma cópia versionada no repo vence. - Pacote instalado no ambiente (
importlib) — se não houver cópia vendida, o módulo é puxado direto dosite-packagesdo seu.venv.
Ou seja: uma dependência que você instala (uv add ...) não precisa ser
clonada e jogada na raiz do repositório para ir pro bundle — basta listá-la
em modules. Um nome que não é cópia vendida nem importável falha o build com
uma mensagem clara.
Nem precisa listar à mão: tempestweb sync
Para não ter o trabalho de manter modules em dia, rode:
Ele lê as [project.dependencies] do seu pyproject.toml, mantém as que
estão instaladas e são puro-Python, e escreve os nomes de import em
[wasm].modules — preservando o que já estava lá (o pacote do seu app, cópias
vendidas). Pacotes com código nativo (numpy, pillow) são pulados — eles vêm
do Pyodide via [wasm].packages — assim como o próprio framework
(tempestweb, pydantic). É idempotente: rodar de novo sem mudar o ambiente
não escreve nada. Basta ter as dependências no .venv e rodar o comando. 🚀
Recap¶
- Capacidades são Web APIs expostas como awaitables tipados em Python.
- Uma API, três caminhos: Modo A chama direto, Modo B proxia por round-trip, Modo C transcreve para JS — a assinatura tipada é a mesma.
- Nos Modos A/B o envelope é o
native_call/native_resultdo contrato de fronteira. - Permissões negadas são fluxo normal, tratadas como exceção tipada.
A capacidade storage se conecta à camada offline — veja
PWA e offline. 🚀
Referência de API
Assinatura de cada capacidade: tempestweb.native.