Ir para o conteúdo

Fullstack web — SSR, WASM e server

O SDK fala com o tempestweb de três jeitos, todos fullstack e todos tipados em Python — sem template, sem JavaScript escrito à mão. Cada um resolve um cenário diferente:

Projeto Onde o Python roda Build? Ideal para
1. SSR + HTMX No servidor, por request Não Páginas server-rendered, SEO, interatividade progressiva
2. SPA WASM No browser (Pyodide) tempestweb build --mode wasm App offline-first, servidor só serve arquivos + API
3. Server-mode No servidor, ao vivo tempestweb build --mode server UI reativa dirigida pelo servidor sobre WebSocket/SSE

Cada seção é um projeto completo e rodável. Comece pela 1 (a mais simples) e suba conforme a necessidade.

O extra [ssr]

Tudo aqui mora no extra [ssr]:

uv add "tempest-fastapi-sdk[ssr]"

O tempestweb é carregado sob demanda — import tempest_fastapi_sdk nunca exige o extra.


Projeto 1 — SSR + HTMX (sem build)

Uma lista de tarefas fullstack renderizada no servidor: páginas tipadas (Page), persistência via BaseRepository, e HTMX servido localmente para adicionar/marcar/excluir sem recarregar a página. Nada é compilado — cada request devolve HTML.

O modelo e a persistência

Um BaseModel do SDK e o AsyncDatabaseManager:

from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column

from tempest_fastapi_sdk import AsyncDatabaseManager, BaseModel


class TaskModel(BaseModel):
    """Um item da lista."""

    __tablename__ = "tasks"

    title: Mapped[str] = mapped_column(String(200), nullable=False)
    done: Mapped[bool] = mapped_column(Boolean(), default=False, nullable=False)


db = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:")

Os widgets tipados

Cada tarefa vira um <li> com botões que disparam HTMX. Todo widget aceita tag= e attrs=, então dá pra emitir HTML semântico e atributos hx-* diretamente:

from tempest_core import Button, Column, Row, Text, Widget


def task_widget(task_id: str, title: str, done: bool) -> Widget:
    """Renderiza uma tarefa como <li> com toggle + excluir."""
    return Row(
        tag="li",
        attrs={"id": f"task-{task_id}"},
        children=[
            Button(
                label="✓" if done else "○",
                attrs={
                    "hx-post": f"/tasks/{task_id}/toggle",
                    "hx-target": f"#task-{task_id}",
                    "hx-swap": "outerHTML",
                },
            ),
            Text(content=title, tag="s" if done else "span"),
            Button(
                label="✕",
                attrs={
                    "hx-delete": f"/tasks/{task_id}",
                    "hx-target": f"#task-{task_id}",
                    "hx-swap": "outerHTML",
                },
            ),
        ],
    )

A página

Um Page tipado declara seus dados como campos e implementa body(). Aqui um formulário HTMX (append no <ul>) e a lista:

from tempest_core import Button, Column, Text, Widget

from tempest_fastapi_sdk.ssr import Page

from src.web.widgets import task_widget


class TasksPage(Page):
    """O documento completo da lista de tarefas."""

    tasks: list[tuple[str, str, bool]]   # (id, title, done)

    def body(self) -> Widget:
        form = Column(
            tag="form",
            attrs={
                "hx-post": "/tasks",
                "hx-target": "#tasks",
                "hx-swap": "beforeend",
                "hx-on::after-request": "this.reset()",
            },
            children=[
                Text(
                    content="",
                    tag="input",
                    attrs={"name": "title", "placeholder": "Nova tarefa", "required": "required"},
                ),
                Button(label="Adicionar", attrs={"type": "submit"}),
            ],
        )
        items = [task_widget(i, t, d) for i, t, d in self.tasks]
        return Column(
            tag="main",
            children=[
                Text(content="Tarefas", tag="h1"),
                form,
                Column(tag="ul", attrs={"id": "tasks"}, children=items),
            ],
        )

As rotas

GET / devolve o documento completo (htmx=True injeta o HTMX local); cada ação devolve só o fragmento (document=False) que o HTMX troca no lugar:

from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from uuid import UUID

from fastapi import FastAPI, Form
from tempest_core import Text

from tempest_fastapi_sdk import BaseRepository
from tempest_fastapi_sdk.ssr import html_response, make_htmx_router

from src.api.dependencies.resources import db
from src.db.models import TaskModel
from src.web.pages import TasksPage
from src.web.widgets import task_widget


@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
    await db.connect()
    await db.create_tables()
    yield
    await db.disconnect()


app = FastAPI(lifespan=lifespan)
app.include_router(make_htmx_router())   # serve /_ssr/htmx.js localmente


@app.get("/")
async def index() -> object:
    async with db.get_session_context() as session:
        rows = await BaseRepository(session, model=TaskModel).list()
    page = TasksPage(
        title="Tarefas",
        tasks=[(str(r.id), r.title, r.done) for r in rows],
    )
    return html_response(page, title="Tarefas", htmx=True)


@app.post("/tasks")
async def create_task(title: str = Form(...)) -> object:
    async with db.get_session_context() as session:
        repo = BaseRepository(session, model=TaskModel)
        task = await repo.add(TaskModel(title=title))
        await session.commit()
        await session.refresh(task)
    return html_response(
        task_widget(str(task.id), task.title, task.done), document=False
    )


@app.post("/tasks/{task_id}/toggle")
async def toggle_task(task_id: UUID) -> object:
    async with db.get_session_context() as session:
        repo = BaseRepository(session, model=TaskModel)
        task = await repo.get_by_id(task_id)
        task.done = not task.done
        await repo.update(task)
        await session.commit()
    return html_response(
        task_widget(str(task.id), task.title, task.done), document=False
    )


@app.delete("/tasks/{task_id}")
async def delete_task(task_id: UUID) -> object:
    async with db.get_session_context() as session:
        await BaseRepository(session, model=TaskModel).delete(task_id)
        await session.commit()
    return html_response(Text(content="", tag="span"), document=False)

Rode com uvicorn app:app --reload e abra http://127.0.0.1:8000.

O que este projeto mostra

  • Page tipado + html_response (documento e fragmento).
  • make_htmx_router servindo o HTMX embutido — sem CDN, offline, amigável a CSP.
  • BaseRepository para o CRUD, tudo async.
  • Interatividade real (add/toggle/delete) sem escrever JavaScript.

Projeto 2 — SPA WASM (Python no browser)

Agora o mesmo Python roda no browser via Pyodide. Você compila o frontend com tempestweb build --mode wasm e o SDK serve o dist/ estático com make_web_app_router — mais uma API JSON do FastAPI por trás para os dados.

O app tempestweb (web/app.py)

O contrato do tempestweb é make_state + view (o mesmo código roda nos dois modos — o transporte é escolhido no build):

from __future__ import annotations

from dataclasses import dataclass

from tempest_core import App, Button, Column, Row, Style, Text, Widget
from tempest_core.style import Edge


@dataclass
class State:
    value: int = 0


def make_state() -> State:
    return State()


def view(app: App[State]) -> Widget:
    return Column(
        style=Style(gap=8.0, padding=Edge.all(16)),
        children=[
            Text(content=f"Count: {app.state.value}", key="label"),
            Row(
                style=Style(gap=4.0),
                children=[
                    Button(label="-", key="dec",
                           on_click=lambda: app.set_state(
                               lambda s: setattr(s, "value", s.value - 1))),
                    Button(label="+", key="inc",
                           on_click=lambda: app.set_state(
                               lambda s: setattr(s, "value", s.value + 1))),
                ],
            ),
        ],
    )

O build

cd web
tempestweb build --mode wasm     # gera web/dist/wasm/ (index.html + bootstrap + wasm + sw)

O SDK não builda — o build fica no fluxo do tempestweb (CLI/CI). O SDK só serve o dist/ pronto.

O host FastAPI

make_web_app_router devolve um APIRouter que serve a SPA com history fallback (rota client-side desconhecida → index.html). Inclua-o por último, para as rotas de API vencerem o catch-all:

from fastapi import APIRouter, FastAPI

from tempest_fastapi_sdk.ssr import make_web_app_router

app = FastAPI()

# 1) Sua API JSON — registrada PRIMEIRO.
api = APIRouter(prefix="/api")


@api.get("/count")
async def count() -> dict[str, int]:
    return {"value": 42}


app.include_router(api)

# 2) A SPA estática — registrada POR ÚLTIMO (catch-all na raiz).
app.include_router(make_web_app_router("web/dist/wasm"))

Rode com uvicorn app:app. GET /api/count responde JSON; qualquer outra rota entrega a SPA, que roda Python no browser e chama sua API.

Monte na raiz e inclua por último

O artefato wasm referencia /sw.js na raiz do site, então monte na raiz da app. E como o router é um catch-all (/{resource:path}), inclua-o depois dos seus routers de API.

Sem mágica no serving

index.html e sw.js saem sempre no-cache; os demais assets usam asset_cache_control. MIME correto pra .wasm/.mjs/.webmanifest, Service-Worker-Allowed: / no worker, traversal bloqueado, e nenhum CSP imposto (o Pyodide precisa de wasm-unsafe-eval) — passe security_headers= pra adicionar o seu.


Projeto 3 — Server-mode (WebSocket/SSE ao vivo)

O mesmo web/app.py do Projeto 2, agora compilado para rodar no servidor: a UI é dirigida ao vivo sobre WebSocket/SSE. Você compila com --mode server e monta com build_web_app.

O build

cd web
tempestweb build --mode server   # gera web/dist/server/ (server.py + app.py + static)

O host FastAPI

build_web_app carrega o app.py do artefato, monta o engine server do tempestweb (rotas /ws + /sse), serve o cliente em /static e o shell em / — a mesma fiação que o server.py gerado faz, in-process. Como o build server referencia /ws na raiz, ele ocupa a raiz; adicione suas rotas de API ao próprio app:

from fastapi import APIRouter

from tempest_fastapi_sdk.ssr import build_web_app

# O app do tempestweb já é um FastAPI — adicione sua API a ele.
app = build_web_app("web/dist/server", title="Contador")

api = APIRouter(prefix="/api")


@api.get("/health")
async def health() -> dict[str, bool]:
    return {"ok": True}


app.include_router(api)

Rode com uvicorn app:app. O browser abre /, conecta em /ws, e cada clique roda o view no servidor — a árvore é diffada e enviada de volta. GET /api/health continua respondendo normalmente.

O shell é a única parte do HTML que a aplicação possui

O index.html do artefato sai do tempestweb build com lang="en", sem <meta name="description">, sem favicon e sem lugar para um nonce de CSP. Passe shell= para servir o seu:

from pathlib import Path

from fastapi import Request

from tempest_fastapi_sdk.ssr import build_web_app

gerado: str = (Path("web/dist/server") / "index.html").read_text()


def shell(request: Request) -> str:
    """Serve o shell gerado com o idioma e o nonce corrigidos."""
    nonce = getattr(request.state, "csp_nonce", "")
    return gerado.replace('<html lang="en">', '<html lang="pt-BR">').replace(
        "<script", f'<script nonce="{nonce}"', 1
    )


app = build_web_app("web/dist/server", shell=shell)

Aceita três formas: uma str (o documento), um Path (lido do disco) ou um callable — invocado por request, que é o que permite um nonce por resposta. O callable pode declarar Request ou nenhum parâmetro. Vale igual no make_web_app_router, onde o shell também responde o fallback de SPA.

Uma str sem < é recusada com erro: é um caminho escrito onde se esperava um documento, e serviria uma página em branco sem avisar.

A paleta da aplicação também entra aqui

theme= entrega uma Theme ao App de cada sessão, e a view a lê de volta como app.theme:

from tempest_core import Theme, ThemeMode
from tempest_core.style import Color

from tempest_fastapi_sdk.ssr import build_web_app

marca: Theme = Theme.from_seed(Color(r=39, g=58, b=79), mode=ThemeMode.SYSTEM)

app = build_web_app("web/dist/server", theme=marca)

Os componentes que a view constrói resolvem a cor contra essa paleta sozinhos — o build instala o tema em volta da chamada da view. Isso exige tempestweb>=0.67.0: é o piso que alcança o tempest-core 0.12.0, onde a ligação existe. Um theme= passado direto num widget ainda vence. A receita completa, com as duas metades do rebrand, está em SSR.

Qual modo escolher?

  • WASM — o cliente é autônomo (roda offline), o servidor é opcional (só API + arquivos). Boot mais pesado (baixa o Pyodide).
  • Server — boot instantâneo e o estado vive no servidor (ideal pra dados sensíveis ou lógica pesada), mas exige conexão viva.

O melhor: o web/app.py é idêntico nos dois — troque só o --mode do build.


Recap

  • SSR + HTMXPage + html_response + make_htmx_router: server-rendered, sem build, interatividade via HTMX local.
  • SPA WASMtempestweb build --mode wasm + make_web_app_router: Python no browser, servido como estático com history fallback + sua API.
  • Server-modetempestweb build --mode server + build_web_app: UI ao vivo sobre WebSocket/SSE, com sua API no mesmo app.
  • detect_build_mode(dir) distingue um dist/ wasm de um server.
  • O SDK só serve um build pronto — compilar fica no tempestweb. Detalhes de cada API em SSR.