# Trilha > Framework web para Go no estilo Next.js: uma pasta em app/ é uma rota, o HTML é escrito em Go e nada fora da biblioteca padrão é importado. --- # Início rápido Source: /trilha/pt/aprender Do zero a uma página no navegador em cinco minutos, e o que aconteceu em cada passo. Nesta trilha você constrói uma **agenda de eventos**: lista, página de detalhe, formulário para cadastrar, uma API JSON e uma área restrita. Cada capítulo adiciona um pedaço e termina com um desafio. Este primeiro só coloca o projeto de pé. ## O que você precisa - Go 1.22 ou mais novo (`go version`). - A pasta de binários do Go no `PATH`: `~/go/bin` (o Go instala programas ali, e não em `/usr/local/go/bin`, que é onde fica o próprio `go`). ```bash # se `trilha` não for encontrado depois do go install, adicione ao seu ~/.zshrc ou ~/.bashrc: export PATH="$HOME/go/bin:$PATH" ``` ## Instale e crie o projeto ```bash go install github.com/emersonjoe/trilha/cmd/trilha@latest trilha new agenda cd agenda trilha dev ``` Abra `http://localhost:3000`. A página inicial já está lá. Deixe o `trilha dev` rodando: ele recompila e recarrega o navegador a cada arquivo salvo. ## O que foi criado ```text agenda/ ├── go.mod ├── trilha_gen.go ← gerado pela CLI; commite, não edite ├── public/style.css ← servido em /style.css └── app/ ├── layout.go ← o de todas as páginas ├── page.go ← GET / ├── not_found.go ← página 404 └── api/hello/route.go ← GET /api/hello ``` A regra que sustenta tudo: **uma pasta dentro de `app/` é um caminho na URL**. O arquivo dentro dela diz o que aquele caminho faz. ## Sua primeira página Crie `app/eventos/page.go`: ```go package eventos import ( "github.com/emersonjoe/trilha" "github.com/emersonjoe/trilha/h" ) func Page(c *trilha.Ctx) (h.Node, error) { c.SetTitle("Eventos") return h.Fragment( h.H1(h.Text("Próximos eventos")), h.P(h.Text("Ainda não há eventos cadastrados.")), ), nil } ``` Salve e visite `/eventos`. Três coisas aconteceram: 1. A CLI viu a pasta nova, regenerou `trilha_gen.go` com a rota `/eventos` e recompilou. 2. `Page` rodou e devolveu um **nó** de HTML, construído com o pacote `h`. 3. O nó foi entregue ao `Layout` de `app/layout.go`, que colocou o `` em volta, e o resultado foi enviado com `Content-Type: text/html`. :::dica `Page` recebe um único argumento, o `*trilha.Ctx`, e devolve `(h.Node, error)`. Toda função de rota no Trilha segue este formato: um contexto de entrada, um erro de saída. Você vai ver o mesmo desenho em layouts, middlewares e rotas de API. ::: ## Desafio Crie `app/sobre/page.go` que responda `/sobre` com um título e um parágrafo, e adicione um link para ela na navegação do `app/layout.go`. Quando salvar, a página deve aparecer sem reiniciar nada. :::solucao ```go // app/sobre/page.go package sobre import ( "github.com/emersonjoe/trilha" "github.com/emersonjoe/trilha/h" ) func Page(c *trilha.Ctx) (h.Node, error) { c.SetTitle("Sobre") return h.Fragment( h.H1(h.Text("Sobre a agenda")), h.P(h.Text("Uma agenda de eventos construída com Trilha.")), ), nil } ``` No `app/layout.go`, dentro do `h.Nav(...)`: ```go h.A(h.Href("/sobre"), h.Text("Sobre")), ``` ::: --- # Páginas e rotas Source: /trilha/pt/aprender/paginas-e-rotas Como pastas viram URLs, incluindo segmentos dinâmicos, catch-all e grupos. Você já viu que `app/eventos/page.go` responde `/eventos`. Este capítulo cobre o resto do mapeamento: parâmetros na URL, caminhos de tamanho variável e pastas que agrupam sem aparecer na URL. ## Segmento dinâmico: `nome_` Cada evento terá uma página própria em `/eventos/encontro-go`. Em vez de uma pasta por evento, crie uma pasta cujo nome termina com `_`: ```text app/eventos/slug_/page.go → GET /eventos/{slug} ``` Dentro da página, o valor vem de `c.Param`: ```go package slug import ( "github.com/emersonjoe/trilha" "github.com/emersonjoe/trilha/h" ) func Page(c *trilha.Ctx) (h.Node, error) { slug := c.Param("slug") c.SetTitle("Evento " + slug) return h.H1(h.Textf("Evento: %s", slug)), nil } ``` O nome do parâmetro é o nome da pasta sem o `_`. Uma pasta `id_` dá `c.Param("id")`. :::nota Por que não `[slug]` como em outros frameworks? Porque a pasta vira um **pacote Go**, e o caminho de import de um pacote não aceita colchetes, chaves nem cifrão. O sufixo `_` é legal, aparece em `go list ./...` e não confunde o shell. ::: ## Catch-all: `nome__` Duas barras no final capturam tudo o que vier depois, com as barras internas: ```text app/docs/caminho__/page.go → GET /docs/{caminho...} ``` `GET /docs/guia/instalacao` chega com `c.Param("caminho") == "guia/instalacao"`. Uma pasta catch-all precisa ser folha: nada pode existir abaixo dela. ## Quem vence quando há empate Rotas literais vencem as dinâmicas. Com `app/eventos/novo/page.go` e `app/eventos/slug_/page.go`, `/eventos/novo` vai para a primeira e `/eventos/qualquer-outra` para a segunda. Duas pastas dinâmicas irmãs (`a_` e `b_` no mesmo nível) são um erro de geração, porque não haveria como escolher. ## Grupos de rota: `nome-` Às vezes você quer que várias páginas compartilhem um layout ou um middleware sem que isso apareça na URL. Uma pasta terminada em `-` é um **grupo**: ```text app/organizador-/middleware.go ← vale para tudo abaixo app/organizador-/painel/page.go → GET /painel (sem "organizador" na URL) app/organizador-/eventos/page.go → GET /eventos ✗ conflita com app/eventos/page.go ``` O gerador recusa duas pastas que produzam a mesma URL (`E_DUPLICATE_ROUTE`), então o segundo exemplo acima não compila. ## Deixando a CLI fazer a tradução Nada acima precisa ser digitado à mão. O `trilha generate` recebe a URL e grava a pasta que a convenção pede, já compilando: ```bash trilha generate page /eventos/{slug} # app/eventos/slug_/page.go trilha generate route /api/eventos # app/api/eventos/route.go ``` A página já vem com `c.Param("slug")` lido, e o `trilha_gen.go` é regerado no fim, então a URL responde antes de você abrir o editor. Com `--methods`, `--bind` e `--form` o esqueleto vem também com o contrato — os handlers, a struct, a validação e o formulário — e o `trilha generate test ` escreve o teste ao lado. As flags estão em [CLI](/trilha/pt/referencia/cli#trilha-generate). ## O que o gerador faz com isso Rode `trilha routes` a qualquer momento para ver a tabela: ```text MÉTODOS PADRÃO ORIGEM GET / app/page.go GET /eventos app/eventos/page.go GET /eventos/{slug} app/eventos/slug_/page.go GET /painel app/organizador-/painel/page.go ``` Essa tabela vira código Go em `trilha_gen.go`: um `a.Register(trilha.Route{...})` por linha, importando cada pacote. Se você renomear `Page`, é o compilador quem reclama, não o servidor em produção. ## Desafio Crie a página de detalhe `app/eventos/slug_/page.go` que mostre o slug, e uma página `app/eventos/hoje/page.go`. Confirme com `trilha routes` que `/eventos/hoje` aponta para a pasta literal e não para a dinâmica. :::solucao As duas páginas seguem o formato de `Page`. A saída de `trilha routes` deve conter: ```text GET /eventos/hoje app/eventos/hoje/page.go GET /eventos/{slug} app/eventos/slug_/page.go ``` A ordem alfabética coloca `/eventos/hoje` antes, mas o que decide a precedência é o roteador: literal antes de dinâmico, sempre. ::: --- # Layouts aninhados Source: /trilha/pt/aprender/layouts Um layout por pasta, do mais interno ao mais externo, e como o título viaja entre eles. Um `layout.go` envolve todas as páginas da sua pasta e das pastas abaixo. O layout de `app/` é o raiz e normalmente é o único que escreve ``. ## A assinatura ```go func Layout(c *trilha.Ctx, children h.Node) (h.Node, error) ``` `children` é a página já renderizada como nó, ou o layout mais interno já aplicado. Você decide onde colocá-la. ## Um layout para a agenda Crie `app/eventos/layout.go`: ```go package eventos import ( "github.com/emersonjoe/trilha" "github.com/emersonjoe/trilha/h" ) func Layout(c *trilha.Ctx, children h.Node) (h.Node, error) { return h.Section(h.Class("agenda"), h.Nav( h.A(h.Href("/eventos"), h.Text("Todos")), h.A(h.Href("/eventos/novo"), h.Text("Novo evento")), ), children, ), nil } ``` Agora `/eventos`, `/eventos/novo` e `/eventos/qualquer` aparecem dentro dessa `
`, que por sua vez aparece dentro do `
` do layout raiz. @demo layout ## A ordem de execução Para `GET /eventos/encontro-go`: 1. `app/eventos/slug_/page.go` → `Page` produz o nó da página. 2. `app/eventos/layout.go` → recebe esse nó como `children`. 3. `app/layout.go` → recebe o resultado do passo 2. De dentro para fora. Uma pasta sem `layout.go` simplesmente não participa. ## Título e outros dados da página para o layout A página roda **antes** dos layouts. Por isso `c.SetTitle("Eventos")` na página funciona no layout raiz, que lê `c.Title()` para montar o ``. O mesmo vale para qualquer valor que você guardar com `c.Set(chave, valor)` e ler com `c.Get(chave)`. ```go // na página c.SetTitle("Encontro Go") c.Set("descricao", "Uma noite de palestras em Campinas") // no layout raiz h.Title(h.Text(c.Title())), h.Meta(h.Name("description"), h.Content(str(c.Get("descricao")))), ``` :::dica Se não existir `app/layout.go`, o Trilha embrulha a página em um `<html>` mínimo. Útil nos primeiros minutos; crie o seu assim que quiser CSS. ::: ## Layouts em grupos de rota Um grupo (`organizador-/`) pode ter layout. Ele vale para as páginas do grupo e conta como um nível na ordem: `página → layout do grupo → layout raiz`. ## Desafio Faça o layout de `app/eventos/` mostrar, abaixo da navegação, um `<p>` com o título da página atual, para confirmar que o título definido em `Page` já está disponível ali. :::solucao ```go func Layout(c *trilha.Ctx, children h.Node) (h.Node, error) { return h.Section(h.Class("agenda"), h.Nav( h.A(h.Href("/eventos"), h.Text("Todos")), h.A(h.Href("/eventos/novo"), h.Text("Novo evento")), ), h.P(h.Class("migalha"), h.Text(c.Title())), children, ), nil } ``` ::: --- # HTML com o pacote h Source: /trilha/pt/aprender/html-com-h Elementos como funções, escape por padrão, condicionais, listas e quando usar templates. O pacote `h` gera HTML sem arquivos de template: cada elemento é uma função Go que aceita atributos e filhos em qualquer ordem. Tudo é verificado pelo compilador e escapado na saída. ## Elementos, atributos e texto ```go h.Article(h.Class("evento", "destaque"), h.H2(h.Text(ev.Nome)), h.P(h.Textf("%s, %d vagas", ev.Cidade, ev.Vagas)), h.A(h.Href("/eventos/"+ev.Slug), h.Text("Detalhes")), ) ``` - `h.Text` e `h.Textf` escapam. `h.Raw` não escapa e é a única porta para HTML pronto. - Atributos (`h.Class`, `h.Href`, `h.ID`, `h.Data("x", v)`, `h.Attr("nome", v)`) podem vir depois dos filhos; eles sempre acabam na tag de abertura. - Elementos vazios (`h.Br`, `h.Img`, `h.Input`, `h.Meta`) não fecham. - Atributos booleanos são funções sem argumento: `h.Required()`, `h.Disabled()`. - Quando o nome colide com um elemento, o atributo ganha o sufixo `Attr`: `h.StyleAttr`, `h.TitleAttr`, `h.LabelAttr`. @demo escape ## Condicionais e listas ```go h.Ul( h.If(len(eventos) == 0, h.Li(h.Em(h.Text("nenhum evento")))), h.Map(eventos, func(ev Evento) h.Node { return h.Li(h.Text(ev.Nome)) }), ) ``` `h.If` devolve um nó vazio quando a condição é falsa; `h.IfElse` escolhe entre dois; `h.Map` aplica uma função a cada item; `h.Fragment` agrupa vários nós sem elemento em volta. `nil` como filho é ignorado, então um `func() h.Node` que devolve `nil` também é seguro. @demo lista ## Componentes são funções Não existe um tipo "componente". Uma função que devolve `h.Node` já é um: ```go func CartaoEvento(ev Evento) h.Node { return h.Article(h.Class("cartao"), h.H3(h.Text(ev.Nome)), h.P(h.Text(ev.Cidade)), ) } // na página h.Div(h.Class("grade"), h.Map(eventos, CartaoEvento)) ``` ## Prefere templates? O pacote `tmpl` encaixa `html/template` no mesmo pipeline. Os arquivos ficam ao lado da página e são embutidos no binário: ```go package relatorio import ( "embed" "github.com/emersonjoe/trilha" "github.com/emersonjoe/trilha/h" "github.com/emersonjoe/trilha/tmpl" ) //go:embed relatorio.html var arquivos embed.FS var t = tmpl.Must(arquivos, "*.html") // falha na subida, nunca no request func Page(c *trilha.Ctx) (h.Node, error) { c.SetTitle("Relatório") return tmpl.Node(t, "relatorio", dados), nil } ``` Layouts, título e erros funcionam igual. O escape é o contextual do próprio `html/template`. ## Desafio Escreva um componente `Vagas(n int) h.Node` que mostre "lotado" em itálico quando `n == 0`, "1 vaga" no singular e "N vagas" no plural, e use-o na lista de eventos. :::solucao ```go func Vagas(n int) h.Node { switch { case n == 0: return h.Em(h.Text("lotado")) case n == 1: return h.Text("1 vaga") default: return h.Textf("%d vagas", n) } } ``` ::: --- # Formulários Source: /trilha/pt/aprender/formularios POST no mesmo page.go, proteção CSRF automática e o padrão redirecionar-depois-de-gravar. Uma página pode receber formulários exportando `POST` (ou `PUT`, `PATCH`, `DELETE`) ao lado de `Page`. O Trilha verifica o token CSRF antes de chamar a sua função. ## A página com o formulário `app/eventos/novo/page.go`: ```go package novo import ( "strings" "github.com/emersonjoe/trilha" "github.com/emersonjoe/trilha/h" "agenda/internal/eventos" ) func Page(c *trilha.Ctx) (h.Node, error) { c.SetTitle("Novo evento") erro := c.Query("erro") return h.Fragment( h.H1(h.Text("Novo evento")), h.If(erro != "", h.P(h.Class("erro"), h.Text(erro))), h.Form(h.Method("post"), h.Action("/eventos/novo"), trilha.CSRFInput(c), h.Label(h.For("nome"), h.Text("Nome")), h.Input(h.ID("nome"), h.Name("nome"), h.Required(), h.Autofocus()), h.Label(h.For("cidade"), h.Text("Cidade")), h.Input(h.ID("cidade"), h.Name("cidade")), h.Button(h.Type("submit"), h.Text("Publicar")), ), ), nil } func POST(c *trilha.Ctx) error { if err := c.FormErr(); err != nil { return err // 400 em formulário inválido, 413 se passou do limite } nome := strings.TrimSpace(c.Form("nome")) if nome == "" { return c.Redirect("/eventos/novo?erro=Informe+o+nome") } ev := eventos.Criar(nome, c.Form("cidade")) return c.Redirect("/eventos/" + ev.Slug) } ``` @demo form ## O que acontece no envio 1. O navegador manda `POST /eventos/novo` com os campos e o `_csrf`. 2. O Trilha compara o `_csrf` com o cookie `trilha_csrf` (tempo constante). Diferente ou ausente: **403**, e `POST` nem roda. 3. `POST` roda e devolve `c.Redirect(...)`: resposta **303 See Other**. O navegador faz um `GET` na URL nova. Recarregar a página não reenvia o formulário. `trilha.CSRFInput(c)` cria o cookie na primeira renderização e o campo oculto. Clientes JavaScript podem mandar o mesmo valor no cabeçalho `X-CSRF-Token`. ## Validação e mensagens O exemplo acima confere o nome na mão e devolve o erro pela query string, o que mantém o padrão POST → redirect → GET e funciona sem JavaScript. Assim que o formulário passa de dois ou três campos, ponha as regras na struct: a tag `validate` fica do lado do campo de que ela fala, e o `Bind` aplica todas antes de voltar. ```go type entrada struct { Nome string `form:"nome" validate:"required,min=3,max=80"` Email string `form:"email" validate:"required,email"` Vagas int `form:"vagas" validate:"min=1,max=10"` } func POST(c *trilha.Ctx) error { var in entrada if err := c.Bind(&in); err != nil { if errs, ok := err.(trilha.FieldErrors); ok { // Mesma página, 422, valores preservados, uma mensagem por campo. return c.Render(http.StatusUnprocessableEntity, formulario(c, in, errs)) } return err } ev := eventos.Criar(in.Nome, in.Email, in.Vagas) return c.Redirect("/eventos/" + ev.Slug) } ``` `FieldErrors` é um `map[string]string` (campo → mensagem), então o formulário lê direto: `ui.Errors(errs, "email")` mostra a mensagem e `ui.InvalidIf(errs, "email")` marca o campo com `aria-invalid`. Nada para no primeiro erro — a pessoa vê tudo de uma vez, não um erro a cada envio. As regras são `required`, `min`, `max`, `len`, `email`, `url`, `oneof` e `eqfield`; a [referência de validação](/trilha/pt/referencia/validacao) tem o que cada uma quer dizer em cada tipo. Duas merecem ser ditas aqui: - **Toda regra além de `required` ignora valor vazio.** Um campo opcional com `min=3` só responde pelo que alguém digitou. - **`required` quer dizer "não é o valor zero".** Onde `0` ou `false` é resposta de verdade, declare o campo como ponteiro (`*int`): ausente continua ausente, e zero chega como zero. As mensagens vêm em inglês. App que fala outra língua chama `trilha.UseValidationPTBR()` no `Setup`, ou escreve as próprias em `trilha.ValidationMessages`. ### Quando a tag não basta Regra sobre o formato de um valor pertence ao tipo — e aí todo formulário que usa o tipo está coberto: ```go type Dinheiro string func (d Dinheiro) Validate() error { if v, err := ParseMoney(string(d)); err != nil || v <= 0 { return errors.New("valor deve ser maior que zero") } return nil } ``` Regra que lê dois campos pertence à struct: dê a ela um `Validate() error` e ela roda no fim, só quando nenhum campo falhou. Regra que se repete de projeto em projeto vira uma tag sua: ```go trilha.AddRule("cep", func(f trilha.Field) bool { return cepValido(f.Text) }) trilha.ValidationMessages["cep"] = "CEP inválido" ``` A fronteira é esta: a tag diz o que um **valor** aceita, não o que o **sistema** aceita. "Essa conta existe" e "essa sala está livre nessa noite" são perguntas para os seus dados, e ficam no seu pacote. Rode depois do `Bind` e junte o resultado no mesmo `FieldErrors`, para os dois tipos de mensagem chegarem na mesma resposta. ## Métodos que o navegador não manda Formulários HTML só enviam GET e POST. Para "apagar", exporte `DELETE` para clientes de API e faça o `POST` da página chamar a mesma lógica: ```go func DELETE(c *trilha.Ctx) error { if !eventos.Apagar(c.Param("slug")) { return trilha.ErrNotFound } return c.Redirect("/eventos") } func POST(c *trilha.Ctx) error { return DELETE(c) } ``` ## Limites O corpo da requisição tem limite de 1 MiB por padrão (`Config.MaxBodyBytes`). Acima disso a resposta é 413 antes de o seu código rodar. ## Desafio Adicione ao formulário um campo `vagas` numérico, aceite só de 1 a 10, e mostre a mensagem ao lado do campo em vez de na página seguinte. :::solucao ```go type entrada struct { Nome string `form:"nome" validate:"required,min=3"` Cidade string `form:"cidade"` Vagas int `form:"vagas" validate:"required,min=1,max=10"` } // No POST, c.Bind(&in) devolve trilha.FieldErrors, e a página é renderizada de // novo com c.Render(http.StatusUnprocessableEntity, ...) e ui.Errors(errs, "vagas"). ``` ::: --- # Rotas de API Source: /trilha/pt/aprender/api route.go com uma função por método HTTP, JSON de entrada e saída e erros com status. Uma pasta com `route.go` responde JSON em vez de HTML. Cada método HTTP é uma função exportada com o mesmo formato de sempre: `func(c *trilha.Ctx) error`. ## Listar e criar `app/api/eventos/route.go`: ```go package eventos import ( "net/http" "strings" "github.com/emersonjoe/trilha" "agenda/internal/eventos" ) func GET(c *trilha.Ctx) error { return c.JSON(http.StatusOK, eventos.Todos()) } func POST(c *trilha.Ctx) error { var in struct { Nome string `json:"nome"` Cidade string `json:"cidade"` } if err := c.BindJSON(&in); err != nil { return err // 400 em JSON inválido, 413 acima do limite } if strings.TrimSpace(in.Nome) == "" { return trilha.Errorf(http.StatusUnprocessableEntity, "nome é obrigatório") } ev := eventos.Criar(in.Nome, in.Cidade) c.Header("Location", "/api/eventos/"+ev.Slug) return c.JSON(http.StatusCreated, ev) } ``` ```bash curl -s localhost:3000/api/eventos curl -s -X POST localhost:3000/api/eventos -d '{"nome":"Oficina de HTTP","cidade":"Recife"}' curl -s -X PUT localhost:3000/api/eventos # 405 com Allow: GET, POST ``` ## Um recurso por slug `app/api/eventos/slug_/route.go` responde `/api/eventos/{slug}`: ```go func GET(c *trilha.Ctx) error { ev, ok := eventos.Buscar(c.Param("slug")) if !ok { return trilha.ErrNotFound // 404 problem+json } return c.JSON(200, ev) } func DELETE(c *trilha.Ctx) error { if !eventos.Apagar(c.Param("slug")) { return trilha.ErrNotFound } c.Writer().WriteHeader(http.StatusNoContent) return nil } ``` ## Erros viram status | Você devolve | Resposta | |---|---| | `nil` | o que você escreveu; 204 se não escreveu nada | | `trilha.ErrNotFound` | 404 em JSON | | `trilha.Errorf(422, "msg")` | 422 com `"detail":"msg"` | | `c.Redirect(url)` | 303 | | qualquer outro `error` | 500 com `"title":"Internal Server Error"`; a mensagem real vai para o log | | `&trilha.Problem{…}` | exatamente o problema que você descreveu | O corpo é *problem details*, do [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457), enviado como `application/problem+json` — o formato que cliente gerado, gateway e teste de contrato já sabem ler: ```json {"type":"about:blank","title":"Not Found","status":404, "instance":"/api/eventos/nao-existe","request_id":"01J…"} ``` O `fields` do 422 continua igual, então o formulário que o lê não muda. E quando o status não basta, descreva o problema: ```go return &trilha.Problem{ Type: "https://exemplo.com/probs/esgotado", Title: "Ingressos esgotados", Status: http.StatusConflict, Detail: "O último lugar saiu há 4 minutos.", Extra: map[string]any{"espera": "/api/eventos/" + ev.Slug + "/espera"}, } ``` Qual formato sai segue o tipo da rota, com o `Accept` como desempate: um `route.go` responde `problem+json`, a não ser que o cliente prefira `text/html` — o navegador na barra de endereço recebe a página de erro, esteja a rota onde estiver. Veja [Erros](/trilha/pt/referencia/erros). ## Documento OpenAPI `trilha openapi` escreve o documento OpenAPI 3.1 das suas rotas de API. Não há anotação para manter em dia: a fonte do documento é o código que responde à requisição. ```bash trilha openapi # escreve openapi.json trilha openapi -o - | jq .paths # na saída padrão trilha openapi --check # no CI: falha quando o arquivo se descolou do código ``` O que ele lê sozinho: | No código | No documento | |---|---| | a pasta dentro de `app/api/` | o caminho, com `id_` virando parâmetro | | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` exportados | uma operação para cada | | o comentário de doc | `summary` (primeira frase) e `description` | | `c.Bind(&in)` / `c.BindJSON(&in)` | `requestBody` com o schema de `in`, mais um 422 | | `c.JSON(status, v)` | aquele status com o schema de `v` | | `c.Writer().WriteHeader(204)` | aquele status sem corpo | | `c.Header("Content-Type", …)` | o media type da resposta | | `trilha.ErrNotFound`, `trilha.Errorf(status, …)`, `&trilha.Problem{Status: …}` | aquele status em `problem+json` | | tags `json` e `validate` | nomes das propriedades, `required`, `maxLength`, `enum`, `format` | O schema sai da mesma tag `validate` que o `Bind` lê, então o documento não promete o que a validação recusa. Toda operação leva também a resposta `default` com o schema [`Problem`](/trilha/pt/referencia/erros): desde a 0.21.0 é essa a forma de todo erro de API. Só rotas de `route.go` entram. Uma página responde HTML para um navegador; ali não há contrato que um cliente possa cobrar de você. ### Quando a dedução não alcança Um middleware, um `c.Query` ou uma pasta com ponto no nome estão fora do que a leitura do handler conta. Escreva no comentário de doc: ```go // GET escreve o mês em CSV. // // openapi:query mes string mês a exportar, AAAA-MM (padrão: o atual) // openapi:response 429 // openapi:tag relatorio func GET(c *trilha.Ctx) error { … } ``` | Diretiva | O que faz | |---|---| | `openapi:response <status> [tipo]` | acrescenta a resposta; sem tipo, `problem+json` | | `openapi:body <tipo>` | o corpo da requisição, quando não vem de um `Bind` | | `openapi:query <nome> <tipo> [descrição]` | um parâmetro de query | | `openapi:tag <nome>` | a tag da operação (padrão: o último segmento fixo do caminho) | Um tipo que ninguém declarou é erro apontando arquivo e handler, não schema vazio publicado como se estivesse certo. ## CSRF em APIs Por padrão, `route.go` **não** exige token CSRF: APIs costumam ser chamadas com token de sessão ou bearer, e o cookie `SameSite=Lax` já protege contra envio automático pelo navegador. Se a sua API for chamada pelo próprio site com cookies, ligue `Config.CSRFForAPI` e mande `X-CSRF-Token`. ## Desafio Adicione `PATCH` em `/api/eventos/{slug}` que atualize só os campos presentes no JSON e responda 200 com o evento novo. JSON com campos desconhecidos deve dar 400. :::solucao `c.BindJSON` já rejeita campos desconhecidos. Para "só os campos presentes", use ponteiros: ```go func PATCH(c *trilha.Ctx) error { var in struct { Nome *string `json:"nome"` Cidade *string `json:"cidade"` } if err := c.BindJSON(&in); err != nil { return err } ev, ok := eventos.Buscar(c.Param("slug")) if !ok { return trilha.ErrNotFound } if in.Nome != nil { ev.Nome = *in.Nome } if in.Cidade != nil { ev.Cidade = *in.Cidade } eventos.Salvar(ev) return c.JSON(200, ev) } ``` ::: --- # Dados e cache Source: /trilha/pt/aprender/dados De onde vem o dado, por quanto tempo vale guardar a resposta e o que a derruba — com cache.Do, tags e cache.Once. O Trilha não tem ORM, não tem repositório e não tem opinião sobre o seu banco: a página chama o seu código, e o seu código chama o que você usa. O que o framework traz é a parte que sempre se escreve à mão e sempre se escreve errado — guardar uma resposta por um tempo, e jogá-la fora quando ela deixa de ser verdade. ```go import "github.com/emersonjoe/trilha/cache" ``` ## O cache é seu, não do framework Não existe `app.Cache()`. Você cria, você diz de que tamanho ele fica, e você o guarda onde mora o código que o enche — em geral o pacote que consulta o banco: ```go // internal/eventos/eventos.go var Cache *cache.Cache // app/setup.go func Setup(a *trilha.App) error { eventos.Cache = cache.New(cache.Options{ Name: "eventos", MaxEntries: 500, Metrics: a.Metrics(), }) return nil } ``` `MaxEntries` tem padrão (10 000) e não tem como dizer "sem limite". Cache sem teto é vazamento de memória que leva uma semana para aparecer: toda chave que alguém consegue inventar — um termo de busca, um filtro na query string — vira uma entrada que nunca sai. Batendo no teto, a entrada usada há mais tempo é despejada. ## `Do`: o valor, ou o jeito de buscá-lo `cache.Do` é o pacote inteiro em uma chamada. Ele devolve o que está guardado, ou executa a sua função e guarda o que ela devolveu: ```go func Proximos(ctx context.Context) ([]Evento, error) { return cache.Do(ctx, Cache, cache.Key{ Name: "eventos:proximos", TTL: 5 * time.Minute, Tags: []string{"eventos"}, }, func(ctx context.Context) ([]Evento, error) { return db.Proximos(ctx) }) } ``` | Campo da `Key` | O que é | |---|---| | `Name` | o endereço do valor; nomes iguais são a mesma entrada | | `TTL` | por quanto tempo ele vale; `0` (ou menos) é sem prazo | | `Tags` | rótulos para invalidar em lote depois | O nome é uma decisão, não um detalhe: tudo o que muda a resposta tem de estar nele. Uma lista que depende da página e de quem está logado é `posts:pagina:2:usuario:42`, e não `posts` — chave de cache que esquece o usuário é como o dado de um vai parar na tela do outro. O erro volta para quem chamou e não é guardado para ninguém. A requisição seguinte tenta de novo. ### Uma busca de cada vez No instante em que uma chave quente vence, todas as requisições que a queriam chegam juntas e todas vão ao banco. O `Do` não deixa: a primeira executa a função, as outras esperam por ela e leem a mesma resposta. É uma busca por chave, não importa quantas requisições estejam na fila atrás dela. ## O que derruba Tempo é o jeito fraco de invalidar — cinco minutos de lista errada são cinco minutos de alguém lendo um post que já foi apagado. O jeito forte é avisar: ```go func Criar(ctx context.Context, e Evento) error { if err := db.Inserir(ctx, e); err != nil { return err } Cache.Invalidate("eventos") return nil } ``` `Invalidate` derruba toda entrada que carrega aquela tag, seja qual for o nome, e devolve quantas derrubou. `Delete(nomes...)` derruba por nome, e `Clear()` esvazia tudo. Ponha a chamada ao lado da escrita, nunca ao lado da leitura. Um cache é invalidado por quem mudou o dado — o código que lê não tem como saber que alguma coisa mudou. ## `Once` não é o cache O layout quer saber quem está logado. O cabeçalho quer o mesmo. Dois componentes dentro da página também. O `cache.Once` responde à pergunta uma vez por requisição: ```go func Layout(c *trilha.Ctx, children h.Node) (h.Node, error) { usuario, err := cache.Once(c, "usuario", func() (*usuarios.Usuario, error) { return usuarios.Buscar(c.Context(), auth.From(c).Subject) }) … } ``` Nada guardado aqui sobrevive à resposta, e é exatamente esse o ponto. Ele não recebe `*Cache`, nem TTL, nem tag, porque não há o que vencer: o valor morre com a requisição que o criou. Use `Once` quando a alternativa é passar um valor por seis assinaturas de função, e `Do` quando a resposta é a mesma para todo mundo. Não troque um pelo outro. O nome de um usuário no `Do` sob a chave `"usuario"` é o nome desse usuário servido para a próxima pessoa que abrir a página. ## Vendo funcionar Com `Options.Metrics`, quatro séries aparecem no `/metrics`, rotuladas pelo nome do cache: ``` trilha_cache_hits_total{cache="eventos"} 1043 trilha_cache_misses_total{cache="eventos"} 61 trilha_cache_evictions_total{cache="eventos"} 0 trilha_cache_entries{cache="eventos"} 61 ``` Acertos sobre acertos mais erros é a taxa de acerto — abaixo de 50 % o TTL está curto demais ou a chave carrega algo que não devia. Despejos subindo é teto baixo demais: o cache está jogando fora justamente o que iam lhe pedir. ## O cache que o navegador guarda O cache de cima poupa ao servidor uma ida ao banco. Este poupa à rede uma resposta inteira: o navegador já tem a página e só pergunta se ela mudou. ```go func Page(c *trilha.Ctx) (h.Node, error) { p, ok := trilha.Use[*posts.Store](c).Get(c.Param("slug")) if !ok { return nil, trilha.ErrNotFound } c.CacheControl("private, no-cache") if c.ETag(p.Atualizado.UTC().Format(time.RFC3339Nano)) { return nil, nil // a cópia do navegador está em dia: 304, sem corpo } c.SetTitle(p.Title) return view(p), nil } ``` `ETag` escreve a etiqueta e diz se a requisição já a trazia. Quando diz que sim, o `304` já foi escrito: devolva `nil, nil` — um corpo ali seria jogado fora. `LastModified` faz o mesmo com uma data, e `CacheControl` escreve o cabeçalho como você digitou. `no-cache` não quer dizer "não guarde"; quer dizer "guarde, mas me pergunte antes de reusar", que é justamente o que provoca o `304`. A etiqueta é uma versão do dado, não um hash da página — e a Trilha não calcula uma por você. Toda resposta carrega um nonce novo de CSP, então um hash do HTML nunca bateria duas vezes. Serve qualquer coisa que se mexa quando o dado se mexe: `updated_at`, um número de revisão, os ids do que foi renderizado. > Uma etiqueta que esquece quem está lendo é o mesmo defeito de uma chave de cache que esquece o > usuário. Se a página muda com o visitante, ponha isso na etiqueta ou não mande nenhuma. Os arquivos em `static/` já fazem isso sozinhos: a impressão digital do `?v=` é a ETag deles, então a segunda visita custa um `304` e nenhum byte. ## Desafio A página de detalhe do evento vai ao banco a cada visita. Guarde-a por uma hora, com uma tag que permita ao `Salvar` derrubar só aquele evento e outra que derrube a seção inteira. :::solution Tags são uma lista, então uma entrada pode pertencer a mais de um grupo: ```go func Buscar(ctx context.Context, slug string) (Evento, error) { return cache.Do(ctx, Cache, cache.Key{ Name: "evento:" + slug, TTL: time.Hour, Tags: []string{"eventos", "evento:" + slug}, }, func(ctx context.Context) (Evento, error) { return db.Buscar(ctx, slug) }) } func Salvar(ctx context.Context, e Evento) error { if err := db.Salvar(ctx, e); err != nil { return err } // O evento mudou: a página dele, e toda lista em que ele aparece. Cache.Invalidate("evento:"+e.Slug, "eventos") return nil } ``` ::: --- # Middleware Source: /trilha/pt/aprender/middleware Interceptar uma subárvore de rotas, passar valores para as páginas e proteger áreas. Um `middleware.go` roda antes de qualquer rota da sua pasta e das pastas abaixo. O da raiz roda em toda requisição; o de um grupo, só nas rotas do grupo. ## A assinatura ```go func Middleware(c *trilha.Ctx, next trilha.Next) error ``` Chame `next()` para seguir. Não chame para interromper. Devolva um erro para que o tratamento padrão responda (redirecionamento, 404, 500). ## Medir tempo em todas as rotas `app/middleware.go`: ```go package app import ( "time" "github.com/emersonjoe/trilha" ) func Middleware(c *trilha.Ctx, next trilha.Next) error { inicio := time.Now() err := next() c.Header("Server-Timing", "app;dur="+time.Since(inicio).String()) return err } ``` O cabeçalho é escrito depois de `next()` mas antes de a resposta ser enviada, porque páginas são renderizadas em memória e escritas de uma vez. ## Proteger a área do organizador Um grupo de rota é o lugar natural para exigir login sem poluir a URL: ```text app/organizador-/middleware.go app/organizador-/painel/page.go → /painel app/organizador-/relatorio/page.go → /relatorio ``` ```go package organizador import "github.com/emersonjoe/trilha" func Middleware(c *trilha.Ctx, next trilha.Next) error { ck, err := c.Cookie("sessao") if err != nil || !sessao.Valida(ck.Value) { return trilha.RedirectCode("/entrar?next="+c.Request().URL.Path, 302) } c.Set("usuario", sessao.Usuario(ck.Value)) return next() } ``` Na página, `c.Get("usuario")` devolve o valor. Valores vivem só durante a requisição. ## Uma regra para um método só Uma pasta costuma ter dois papéis: um `GET` que qualquer um da área lê e um `POST` que só um editor manda. Colocar a permissão na primeira linha do handler funciona — até a décima primeira rota, onde alguém esquece. O `middleware.go` aceita o método no nome: ```go package organizador import ( "net/http" "github.com/emersonjoe/trilha" ) // Quem chegou até aqui lê. func Middleware(c *trilha.Ctx, next trilha.Next) error { c.Set("area", "organizador") return next() } // Só editor escreve, nesta pasta e nas de baixo. func MiddlewarePOST(c *trilha.Ctx, next trilha.Next) error { if c.Get("papel") != "editor" { return trilha.Errorf(http.StatusForbidden, "só editores mudam a meta") } return next() } ``` Valem `MiddlewareGET`, `MiddlewarePOST`, `MiddlewarePUT`, `MiddlewarePATCH` e `MiddlewareDELETE`. Eles herdam pela subárvore igual ao `Middleware`, e rodam por dentro dele — a rota decide primeiro, o método refina depois. Um `MiddlewareX` que não alcança nenhuma rota com aquele método é erro de geração (`E_UNUSED_METHOD_MIDDLEWARE`): uma permissão que não guarda nada é exatamente a falha que esta convenção existe para evitar. O 403 acima sai pelo `app/error.go`, com o layout do app; veja [Erros](/trilha/pt/referencia/erros). ## Ordem Para `GET /painel`: ```text middleware(app) → middleware(app/organizador-) → middlewareGET(app) → middlewareGET(app/organizador-) → Page → layouts ``` De fora para dentro, a cadeia da rota antes da cadeia do método. Se um middleware não chamar `next()`, os de dentro e a página não rodam, mas os de fora terminam normalmente (o de medição acima ainda escreve o cabeçalho). ## Curto-circuito com resposta própria Um middleware pode responder diretamente e devolver `nil`: ```go if c.Request().Header.Get("X-Manutencao") == "1" { return c.Text(503, "em manutenção") } ``` Como a resposta já começou, o Trilha não tenta escrever outra. ## Desafio Crie `app/api/middleware.go` que exija o cabeçalho `Authorization: Bearer <chave>` em toda a API e responda 401 em JSON quando faltar, sem afetar as páginas HTML. :::solucao ```go package api import ( "net/http" "strings" "github.com/emersonjoe/trilha" ) func Middleware(c *trilha.Ctx, next trilha.Next) error { auth := c.Request().Header.Get("Authorization") if !strings.HasPrefix(auth, "Bearer ") || !chaves.Valida(strings.TrimPrefix(auth, "Bearer ")) { return trilha.Errorf(http.StatusUnauthorized, "chave inválida") } return next() } ``` Como a pasta é `app/api/`, só as rotas de API passam por ele, e o erro sai em JSON porque a rota é `route.go`. ::: --- # Segurança Source: /trilha/pt/aprender/seguranca O que o Trilha protege por padrão, como ajustar, e o que continua sendo responsabilidade sua. O Trilha segue duas referências: o **NIST Cybersecurity Framework 2.0** (as funções Identificar, Proteger, Detectar, Responder, Recuperar e Governar) e o **OWASP ASVS 4.0** nível 2. Um framework web só consegue *proteger* e *detectar*; o restante é trabalho de quem opera o app, e este capítulo diz exatamente onde termina um e começa o outro. ## O que já vem ligado | Controle | Padrão | NIST CSF 2.0 | OWASP ASVS | |---|---|---|---| | Escape de HTML (`h`) e escape contextual (`tmpl`) | sempre | PR.DS | V5.3 | | `Content-Security-Policy` com nonce por requisição | ligado | PR.PS | V14.4 | | `Strict-Transport-Security` | ligado em HTTPS | PR.DS | V9.1 | | `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy` | ligados | PR.PS | V14.4 | | CSRF por *double-submit cookie* em formulários | ligado | PR.AA | V4.2 | | Limite do corpo da requisição (1 MiB) | ligado | PR.IR | V13.1 | | Timeouts de leitura, escrita e ociosidade; limite de cabeçalhos | ligados | PR.IR | V13.1 | | Estáticos restritos a `public/` | sempre | PR.DS | V12.3 | | Erros opacos em produção; sem stack, sem caminho | ligado | PR.DS | V7.4 | | Logs estruturados sem corpo nem cookies, com `request_id` | sempre | DE.CM | V7.1 | | Eventos de segurança (CSRF, 401/403, 413, 429, panic) no log | sempre | DE.AE | V7.2 | | Cookies assinados (`SetSigned`/`Signed`) | com `TRILHA_SECRET` | PR.AA | V3.4 | | Limite de taxa por cliente | opcional | PR.IR | V11.1 | | Proxies confiáveis (`X-Forwarded-*`) | opcional | PR.AA | V14.1 | ## CSP e scripts inline A política padrão só permite scripts do próprio site ou com o **nonce** da requisição. Um `<script>` inline precisa dele: ```go h.Script(trilha.NonceAttr(c), h.Raw(`document.body.dataset.pronto = "1"`)) ``` O script de recarga do `trilha dev` já usa o nonce. Para liberar uma origem externa (fontes, CDN de imagens) sem reescrever a política, acrescente em `app/setup.go`: ```go func Setup(a *trilha.App) error { a.Security().CSPExtra = map[string][]string{ "style-src": {"https://fonts.googleapis.com"}, "font-src": {"https://fonts.gstatic.com"}, } return nil } ``` Para uma política totalmente sua, defina `a.Security().CSP` (a string pode conter `{nonce}`); para desligar um cabeçalho, atribua `trilha.Off`. ## Atrás de um proxy Se o app roda atrás de nginx, Caddy ou um balanceador, o `RemoteAddr` é o proxy. Diga ao Trilha em quem confiar para que `X-Forwarded-For` e `X-Forwarded-Proto` valham: ```bash TRILHA_TRUSTED_PROXIES=10.0.0.0/8,127.0.0.1 ``` Só então `c.ClientIP()` devolve o cliente real, o HSTS é enviado e o limite de taxa conta por cliente e não por proxy. Sem essa variável, cabeçalhos `X-Forwarded-*` são ignorados, o que é o comportamento seguro. ## O host que você atende O cabeçalho `Host` é escolhido por quem chama. Seu app o usa para montar URL absoluta — o link de redefinição de senha, o e-mail de convite, um redirecionamento — e qualquer cache à frente o usa como chave. Uma requisição com `Host: atacante.example` basta para pôr num e-mail que o seu app envia um link apontando para o domínio de outra pessoa. Liste os hosts que você atende e o resto leva 400 antes de o roteador rodar: ```go trilha.Config{AllowedHosts: []string{"exemplo.com", "*.exemplo.com"}} ``` ```bash TRILHA_ALLOWED_HOSTS=exemplo.com,*.exemplo.com ``` A porta e a caixa não contam, então `exemplo.com:8443` passa. `*.exemplo.com` libera um rótulo a mais — `app.exemplo.com` sim, `a.b.exemplo.com` não. Em `Dev`, `localhost` e os endereços de loopback passam sempre, então copiar a lista de produção para a configuração de desenvolvimento não quebra o dev server. Lista vazia não confere nada, que é o que recebe o app que nunca ouviu falar disso. A recusa é um evento de segurança de tipo `host`: aparece no log, na métrica e no `OnSecurityEvent` como todo bloqueio. :::note A lista fala do host que o **app** recebe. Se um proxy reescreve o `Host`, escreva o que o proxy manda, não o que o navegador digitou. ::: ## Sessão com cookie assinado Um cookie assinado não pode ser forjado nem alterado, e vence sozinho: ```go // no POST do login if err := c.SetSigned("sessao", usuario.ID, 8*time.Hour); err != nil { return err } // no middleware da área restrita id, ok := c.Signed("sessao") if !ok { return trilha.RedirectCode("/entrar", 302) } ``` A chave vem de `TRILHA_SECRET` (32 bytes ou mais; `openssl rand -base64 32`). Em desenvolvimento o `trilha dev` gera uma chave efêmera por sessão. Em produção sem a variável, `SetSigned` devolve erro e registra um aviso dizendo qual cookie, em qual rota — uma vez por cookie, e só para quem de fato assina algum: um aviso em toda subida de um app que tem sessão própria é o que ensina a equipe a não ler aviso. Para trocar a chave sem derrubar sessões, coloque a antiga em `TRILHA_SECRET_PREVIOUS` até que expirem. :::atencao O cookie assinado garante integridade, não sigilo: o valor é legível por quem tem o cookie. Guarde nele um identificador, nunca dados sensíveis. ::: ## Limite de taxa Global, em `app/setup.go`, ou por subárvore, em um `middleware.go`: ```go // app/api/middleware.go var limit = trilha.Limit(5, 20) // 5 req/s por cliente, rajada de 20 func Middleware(c *trilha.Ctx, next trilha.Next) error { return limit(c, next) } ``` A resposta é 429 com `Retry-After`. O contador vive na memória do processo: com várias réplicas, cada uma conta a sua parte. ## Detectar e responder Cada bloqueio gera uma linha `security` no log, com `kind`, `ip`, `path` e `request_id`, e chama `Config.OnSecurityEvent` se você definir um. É o gancho para contar tentativas, alertar ou bloquear um IP no firewall. Antes de publicar, rode: ```bash trilha audit ``` Ele confere `TRILHA_SECRET`, proxies, `trilha_gen.go`, versão do Go, `go vet` e `govulncheck`, e sai com erro se houver item crítico. ## Arquivo que chega de fora Upload é a única requisição em que o app grava o que outra pessoa mandou, com o nome que outra pessoa escolheu. O `c.File` só devolve o arquivo depois das três conferências que importam: ```go func POST(c *trilha.Ctx) error { c.AllowBody(8 << 20) // a requisição; o limite do arquivo, abaixo, é outro up, err := c.File("arquivo", trilha.FileRules{ MaxSize: 4 << 20, Accept: []string{"image/*", "application/pdf"}, }) if err != nil { if errs, ok := err.(trilha.FieldErrors); ok { return c.Render(http.StatusUnprocessableEntity, pagina(c, errs)) } return err } defer up.Close() caminho, err := up.Save("uploads") // nunca sai de "uploads" ... } ``` - **Tamanho**: `MaxSize` é por arquivo, à parte do `Config.MaxBodyBytes`. A rota que aceita um arquivo de 4 MB ainda precisa deixar passar um corpo um pouco maior (`c.AllowBody`), porque o corpo leva também os outros campos. - **Tipo**: `Accept` é comparado com o tipo detectado nos primeiros 512 bytes do conteúdo, nunca com a extensão e nunca com o `Content-Type` que o cliente anunciou — um PDF renomeado para `foto.png` é um PDF. `up.MIME` é o que ele é de verdade e `up.Ext` é a extensão correspondente. Atenção: a biblioteca padrão detecta o que conhece; formato que é zip por dentro (`.docx`, `.xlsx`) volta como `application/zip`, e CSV como `text/plain`. Onde a diferença importa, olhe o conteúdo você mesmo. - **Nome**: `up.Name` não tem diretório, nem separador de nenhuma das duas plataformas, nem caractere de controle, tem no máximo 100 caracteres, e nunca é vazio nem `..`. O `up.Save(dir)` grava dentro de `dir` com modo 0600 e um nome livre (`nota.pdf`, depois `nota-1.pdf`), então o segundo envio não come o primeiro. Regra que falha vira `FieldErrors` no nome do campo — a mesma resposta do `Bind`, então o formulário mostra a mensagem onde a pessoa está olhando, em vez de o app responder 500. Duas coisas continuam sendo suas: **onde** o arquivo vai parar (um diretório fora do código, um bucket, um banco) e **quem** pode mandar. E arquivo que o app devolve sai por uma rota sua, com o tipo que você decidiu — nunca entregando o diretório de upload para o `http.FileServer`. ## Outra origem chamando seu app O navegador só deixa um script ler a resposta de outra origem quando o servidor autoriza. Autorize em um lugar só, `Config.CORS`, com a lista de origens escrita por extenso: ```go func Config(cfg *trilha.Config) error { cfg.CORS = trilha.CORS{ Origins: []string{"https://painel.exemplo.com"}, Methods: []string{"GET", "POST", "DELETE"}, MaxAge: 10 * time.Minute, } return nil } ``` Com isso o preflight `OPTIONS` é respondido pelo framework, antes do roteador — então vale em qualquer rota, inclusive nos arquivos estáticos — e toda resposta para uma origem liberada leva `Access-Control-Allow-Origin` e `Vary: Origin`. Três coisas que o middleware caseiro costuma errar, e que este não erra: - **`"*"` com credencial é recusado na subida, não em runtime.** `Origins: []string{"*"}` serve uma API pública; no instante em que você põe `Credentials: true` do lado, o `New` entra em pânico. O "conserto" habitual dessa dupla — ecoar de volta qualquer `Origin` que chegue — entrega a sessão dos seus usuários a qualquer site que peça. - **A lista de origens é exata.** Sem subdomínio curinga: `https://app.exemplo.com` é uma entrada, e `exemplo.com.atacante.net` nunca casa por acidente. - **O `Vary: Origin` sempre sai**, para um cache na frente do app nunca servir a resposta da origem liberada para outra pessoa. Preflight de origem que ninguém listou volta 403 — o navegador está perguntando, e resposta clara é o que aparece na aba de rede. Já a requisição **simples** de origem não listada é servida como sempre, só que sem os cabeçalhos de CORS: quem esconde a resposta do script é o navegador, e o cliente que não é navegador nunca foi quem estava sendo protegido aqui. ### Quando só alguns caminhos são públicos `Config.CORS` é o app inteiro. Um documento de descoberta em `/.well-known/`, buscado de outra origem por um cliente que ainda não tem sessão, são três caminhos em noventa — e abrir os outros oitenta e sete para consertar três é trocar uma lacuna por uma superfície. Essas rotas levam a própria política, no `route.go` que as serve: ```go var CORS = trilha.CORS{Origins: []string{"*"}, Methods: []string{"GET"}} ``` A rota responde ao próprio preflight, com as mesmas checagens e o mesmo 403; o resto do app segue de mesma origem. A rota que declara política decide sozinha — a lista do app não a estreita, e ela não alarga a lista do app para mais ninguém. Veja [Convenções](/trilha/pt/referencia/convencoes). ## O que continua sendo seu - **Autenticação e autorização**: quem é o usuário e o que pode fazer. O Trilha dá o cookie assinado e o middleware; a regra de negócio é sua. - **Dados em repouso**: criptografia do banco, backups, retenção. - **TLS**: termine no proxy ou use um certificado no próprio `http.Server` via `a.Handler()`. - **Segredos**: só em variáveis de ambiente ou em um cofre; nunca no repositório. - **Governar, Identificar, Recuperar**: inventário, classificação de dados, plano de resposta e restauração são processos da organização. O `SECURITY.md` do projeto descreve como relatar vulnerabilidades do framework, e o [SECURITY-MODEL.md](https://github.com/emersonjoe/trilha/blob/main/docs/pt-BR/SECURITY-MODEL.md) é o modelo de ameaças escrito: contra o quê cada controle defende e o que continua aberto. ## Desafio Faça a área `/painel` do seu app exigir sessão assinada, com um limite de 10 tentativas por minuto no formulário de login, e registre em `OnSecurityEvent` quantos bloqueios ocorreram. :::solucao ```go // app/entrar/middleware.go var limit = trilha.Limit(10.0/60, 10) func Middleware(c *trilha.Ctx, next trilha.Next) error { return limit(c, next) } // app/setup.go var bloqueios atomic.Int64 func Setup(a *trilha.App) error { a.Config().OnSecurityEvent = func(e trilha.SecurityEvent) { if e.Kind == "rate" { bloqueios.Add(1) } } return nil } ``` `a.Config()` dá acesso à configuração dentro de `Setup`; o limitador do login usa `trilha.Limit` com 10 fichas por minuto. ::: --- # Saúde e observabilidade Source: /trilha/pt/aprender/observabilidade Sondas de vida e prontidão, métricas no formato Prometheus e correlação de log, com o cuidado de não transformar monitoração em vazamento. Um app em produção precisa responder três perguntas para quem o opera: *está de pé?*, *pode receber tráfego?* e *o que está acontecendo?*. O Trilha responde às três sem dependência nenhuma, e responde de um jeito que não entrega o mapa da sua infraestrutura para quem passar na rua. A referência aqui é dupla, como no capítulo de segurança: o **NIST SP 800-53r5** (AU-2 e AU-3 para o conteúdo do registro, AU-9 para proteger essa informação, SI-4 para monitoração e SC-5 contra negação de serviço) e o **OWASP** (Top 10 2021 A09, API Security 2023 API8 e o capítulo V7 do ASVS). ## As duas sondas Sem configurar nada, todo app Trilha já responde: | Endereço | Pergunta | Executa verificações? | |---|---|---| | `/_trilha/health/live` | o processo consegue atender? | não | | `/_trilha/health/ready` | pode receber tráfego? | sim | | `/_trilha/health` | igual a `ready` | sim | A separação não é burocracia. No Kubernetes, uma *readiness* que falha tira o pod do balanceador; uma *liveness* que falha **mata o processo**. Se as duas rodassem a mesma verificação de banco, uma oscilação de rede reiniciaria a frota inteira em vez de esperar o banco voltar. Por isso `live` nunca toca em dependência alguma. ```go // app/setup.go func Setup(a *trilha.App) error { a.Check("banco", func(ctx context.Context) error { return db.PingContext(ctx) }) a.Check("fila", func(ctx context.Context) error { return fila.Ping(ctx) }) return nil } ``` Cada verificação roda com prazo (2 s por padrão) e em paralelo; um `panic` dentro dela vira falha, não derruba o processo. O resultado fica em cache por 1 s: uma sonda por segundo — ou dez mil por segundo, vindas de alguém mal-intencionado — não viram dez mil `SELECT 1` no seu banco. ## O que o anônimo vê Em produção, sem autorização, a resposta é exatamente esta: ```json {"status":"fail"} ``` Nome da verificação, mensagem de erro, hostname e versão ficam de fora de propósito (ASVS V7.4.1). Saber que existe um Postgres chamado `financeiro` e que ele está fora do ar é meio caminho para quem está sondando o alvo. A causa vai para o log, onde já existe controle de acesso, e para quem se autentica: ``` curl -H "Authorization: Bearer $TRILHA_OBS_TOKEN" https://app/_trilha/health ``` ```json {"status":"fail","checks":[{"name":"banco","status":"fail","duration_ms":2001.4, "error":"prazo esgotado: context deadline exceeded"}],"uptime_seconds":8134.2} ``` Em `dev` o detalhe é aberto — lá o alvo é você mesmo. ## Métricas O endereço de métricas **não existe** até você pedir. Isso é deliberado: um `/metrics` público é a má configuração descrita no API8 do OWASP, e ele conta ao visitante quantas rotas você tem, quais têm erro e a que horas o tráfego cai. ```go func Config(cfg *trilha.Config) { cfg.Observability.Metrics = "/_trilha/metrics" // ou TRILHA_METRICS // TRILHA_OBS_TOKEN (32+ bytes) autoriza a raspagem; // alternativa: Trusted com o CIDR do coletor. cfg.Observability.Trusted = []string{"10.42.0.0/16"} } ``` A saída é o formato de texto do Prometheus, então Prometheus, VictoriaMetrics, Grafana Alloy e OpenTelemetry Collector leem sem tradutor: ``` trilha_requests_total{method="GET",route="/blog/{slug}",status="200"} 1841 trilha_request_duration_seconds_bucket{method="GET",route="/blog/{slug}",le="0.05"} 1802 trilha_requests_in_flight 3 trilha_security_events_total{kind="csrf"} 2 trilha_panics_total 0 go_goroutines 14 ``` Repare no rótulo `route`: é o **padrão registrado**, `/blog/{slug}`, nunca o caminho concreto `/blog/como-fiz-x`. Caminho concreto é entrada do usuário — traz identificador, às vezes traz token na query string, e faz o número de séries crescer sem limite até a memória acabar. O que não casa com rota registrada (estático, 404) cai num único rótulo `other`, e cada métrica tem teto de séries (mil por padrão). Métrica sua entra no mesmo lugar: ```go posts.Publicados = a.Metrics().Counter("blog_posts_total", "Posts publicados.") lentidao := a.Metrics().Histogram("blog_render_seconds", "Tempo de render.", nil, "template") lentidao.With("post").Observe(dur.Seconds()) ``` ## Achar uma requisição no log Todo log de requisição já sai com `request_id`, e o mesmo valor volta no cabeçalho `X-Request-ID`. Quando o cliente manda `traceparent` (W3C Trace Context — é o que um gateway, um Istio ou um SDK de OpenTelemetry mandam), o `trace_id` entra junto: ```go func GET(c *trilha.Ctx) error { c.Log().Info("consultando fornecedor", "cnpj", cnpj) // request_id + trace_id return c.JSON(200, resp) } ``` O Trilha **propaga** o contexto e o coloca no log; ele não exporta spans nem amostra traços. Rastreamento distribuído completo é trabalho de um coletor, e prendê-lo ao core custaria dezenas de dependências. O que nunca entra no log, por decisão de projeto: corpo da requisição, cookies, cabeçalho `Authorization` e query string (ASVS V7.1.1). É lá que segredo viaja. ## Custo Com o endereço de métricas desligado, a instrumentação não roda: é uma comparação de ponteiro. Ligada, ela custa **zero alocações** por requisição (duas buscas em mapa com chave montada na pilha e alguns incrementos atômicos); a diferença de tempo fica dentro do ruído da máquina de referência. Os números estão em [Desempenho](/trilha/pt/referencia/desempenho). ## Desafio Faça o `/_trilha/health/ready` do seu app verificar o banco **e** um serviço externo, com prazo de 500 ms para o externo; exponha as métricas só para a rede `10.0.0.0/8`; e conte, numa métrica sua, quantas vezes o serviço externo falhou. :::solucao ```go // app/setup.go func Config(cfg *trilha.Config) { cfg.Observability.Metrics = "/_trilha/metrics" cfg.Observability.Trusted = []string{"10.0.0.0/8"} } func Setup(a *trilha.App) error { falhas := a.Metrics().Counter("integracao_falhas_total", "Falhas na consulta ao parceiro.", "servico") a.Check("banco", func(ctx context.Context) error { return db.PingContext(ctx) }) a.Check("parceiro", func(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) defer cancel() if err := parceiro.Ping(ctx); err != nil { falhas.With("parceiro").Inc() return err } return nil }) return nil } ``` O prazo curto do parceiro convive com o prazo geral (`Observability.Timeout`): vale o que vencer primeiro. E como o contador é criado no `Setup`, ele aparece na raspagem desde a primeira requisição, com valor zero — o que é melhor do que sumir do painel até a primeira falha. ::: --- # Autenticação com Entra ID, Keycloak, Cognito e Clerk Source: /trilha/pt/aprender/autenticacao Login OpenID Connect com PKCE, sessão assinada, papéis e logout federado, sem dependência externa e sem senha no seu banco. Quase todo app interno chega no mesmo ponto: alguém pergunta "dá para entrar com a conta da empresa?". A resposta é OpenID Connect — o Entra ID (antigo Azure AD) e o Keycloak falam o mesmo protocolo, e o pacote `auth` implementa o lado do app com a biblioteca padrão. A vantagem não é só comodidade. Senha que você não guarda é senha que você não vaza; MFA, bloqueio por tentativa e política de rotação passam a ser problema do provedor, que tem um time cuidando disso. O que sobra para o app é o pedaço que ninguém pode terceirizar: validar o token direito e manter a sessão em ordem. ## O fluxo, em três rotas O código de autorização com PKCE tem quatro passos: o app manda o navegador ao provedor, a pessoa se autentica lá, o provedor devolve um código para uma rota sua, e o app troca esse código por um `id_token` — essa última troca acontece servidor a servidor, não passa pelo navegador. No `app/`, isso são três arquivos: ```go // app/entrar/route.go var Kind = trilha.KindPage func GET(c *trilha.Ctx) error { return sso.Start(c) } // app/entrar/retorno/route.go var Kind = trilha.KindPage func GET(c *trilha.Ctx) error { return sso.Callback(c) } // app/sair/route.go var Kind = trilha.KindPage func POST(c *trilha.Ctx) error { return sso.Logout(c) } ``` `sso` aqui é um pacote seu, de umas 30 linhas, que lê o ambiente e guarda o `*auth.Auth` (veja `examples/sso/internal/sso`). O `auth` não registra rota nenhuma: quem decide os endereços é o `app/`, como em todo o resto do framework. ## Configurar o provedor ```go p := auth.EntraID(os.Getenv("SSO_TENANT"), id, segredo, "https://app.exemplo/entrar/retorno") // ou p := auth.Keycloak("https://kc.exemplo", "producao", id, segredo, redirect) // ou p := auth.Cognito("us-east-1", "us-east-1_ABC123", id, segredo, redirect) // ou qualquer provedor conforme, pelo emissor: p := auth.OIDC("https://accounts.exemplo/", id, segredo, redirect) flow := auth.New(p, auth.Options{LoginPath: "/entrar", AfterLogin: "/painel"}) ``` Nada aí faz chamada de rede: a descoberta (`/.well-known/openid-configuration`) acontece no primeiro login e fica em cache por uma hora. Um provedor fora do ar não impede o app de subir — impede só de entrar, que é o comportamento honesto. O segredo do cliente **nunca** vai no código. `trilha audit` reclama se encontrar um literal na posição dele, e um segredo que foi para o git precisa ser rotacionado no provedor, não apenas removido do arquivo. ## O Cognito e o logout que não é padrão O `auth.Cognito("us-east-1", "us-east-1_ABC123", …)` monta o emissor `https://cognito-idp.<região>.amazonaws.com/<id-do-user-pool>` e lê os papéis de `cognito:groups`, onde um user pool guarda os seus grupos. Falta uma peça, e é uma peça que o padrão não cobre: **o Cognito não publica `end_session_endpoint`**. Encerrar a sessão lá é um `GET /logout` no domínio de managed login, com outro nome de parâmetro (`logout_uri`, não `post_logout_redirect_uri`): ```go p := auth.Cognito("us-east-1", "us-east-1_ABC123", id, segredo, redirect) p.LogoutDomain = "exemplo.auth.us-east-1.amazoncognito.com" // ou o seu domínio ``` Sem `LogoutDomain`, o `Logout` apaga o cookie, escreve no log que só deu para fazer o logout local e volta para `AfterLogout` — não inventa uma federação que não existe. A URL de retorno precisa estar nas *Allowed sign-out URLs* do app client, senão o Cognito recusa. ## Clerk, e a metade de atalho que ele pode ser O `auth.Clerk` recebe a *Frontend API URL* do painel — `verb-noun-00.clerk.accounts.dev` em desenvolvimento, `clerk.seu-dominio.com` em produção — com ou sem o esquema e a barra final, e transforma as quatro grafias no único emissor que o documento de descoberta do Clerk declara: ```go p := auth.Clerk("verb-noun-00.clerk.accounts.dev", id, secret, redirect) ``` No painel a aplicação é uma **OAuth application** (Configure → OAuth applications): o callback é o seu `RedirectURL` e o segredo aparece uma vez só. Descoberta, PKCE e troca de código são os de sempre daí em diante. Duas coisas o Clerk não tem, e o atalho diz isso em vez de fingir paridade: - **Não há claim de papel.** O `id_token` do Clerk traz a organização (`org_id`), não o papel da pessoa nela. Os papéis caem no par genérico `roles`/`groups` — se a sua instância estiver configurada para mandar alguma claim, aponte o nome em `Options.RoleClaims`. - **Não há `end_session_endpoint`**, nem endereço equivalente como o do Cognito, e o documento traz backchannel e frontchannel logout desligados. O `Logout` apaga o cookie, volta para o `AfterLogout` e escreve no log que a sessão do Clerk ficou de pé. :::nota Tudo acima foi lido de um documento de descoberta real, não da documentação: `https://clerk.clerk.com/.well-known/openid-configuration` é a instância de produção da própria Clerk. Se a sua anunciar mais que isso — uma claim de papel, um endpoint de encerramento —, o `Options.RoleClaims` cobre a primeira e o segundo é usado sozinho. ::: ## Outros provedores Qualquer coisa que fale OIDC funciona pelo `auth.OIDC`, apontando o emissor: os papéis saem de `roles`/`groups`, e um nome diferente de claim entra em `Options.RoleClaims`. É assim que o Google entra. O atalho só evita que você erre o emissor e sabe onde aquele provedor guarda os papéis — é comodidade, não capacidade. ## Proteger uma parte do app É um `middleware.go`, igual a qualquer outro: ```go // app/painel/middleware.go func Middleware(c *trilha.Ctx, next trilha.Next) error { return sso.Require(c, next) } // app/painel/relatorio/middleware.go — exige papel, não só sessão func Middleware(c *trilha.Ctx, next trilha.Next) error { return sso.RequireAdmin(c, next) } ``` Abaixo do middleware a página lê `flow.User(c)` e confia: o `*auth.User` está lá, com `Subject`, `Email`, `Name` e `Roles`. Duas respostas diferentes para duas situações diferentes: - **anônimo**: navegador vai para `/entrar?next=/painel`; qualquer outro cliente recebe **401**. Redirecionar uma chamada de API para um formulário HTML só produz um erro de parsing difícil de entender do outro lado. - **logado, mas sem o papel**: **403**. Mandar para o login quem já está logado cria um laço — a pessoa entra de novo, volta, e leva 401 outra vez. ## Onde moram os papéis Cada provedor guarda em um lugar, e o `auth` já sabe onde procurar: | Provedor | Lê de | |---|---| | Entra ID | `roles` (app roles), `groups`, `wids` | | Keycloak | `realm_access.roles` e `resource_access[seu-cliente].roles` | | Cognito | `cognito:groups` (os grupos do user pool) | | Clerk | nada próprio: o `id_token` tem `org_id`, não o papel | | Genérico | `roles`, `groups` | Papéis do Keycloak que pertencem a **outro** cliente não entram: quem é `admin` no cliente de contabilidade não vira `admin` no seu. Se a sua instalação usa outro nome de claim, acrescente-o em `Options.RoleClaims`. ## A sessão Depois do login, o `id_token` cumpriu o papel dele e é descartado. O que fica é um cookie assinado com `TRILHA_SECRET`, `HttpOnly`, `SameSite=Lax` e `Secure` sob HTTPS, contendo o essencial: identificador, nome, e-mail, papéis e prazos. ```go auth.Options{ Absolute: 8 * time.Hour, // prazo máximo, contado do login Idle: 30 * time.Minute, // some depois de parado Store: auth.NewMemoryStore(), // opcional: revogação imediata } ``` Sem `Store`, a sessão é apátrida: vale em qualquer réplica e não precisa de banco, mas só termina de verdade quando vence. Com um `Store`, o cookie carrega um identificador e o logout apaga o registro na hora — é o que você quer se precisa desligar alguém agora. O identificador muda a cada login, então um cookie plantado antes não vira sessão válida. ## O que o `auth` recusa Cada item aqui é um ataque conhecido, e todos têm teste na suíte: - **`alg` do token**: a lista é fixa (RS256/384/512, ES256/384). Ler o algoritmo do token e obedecer é como bibliotecas de JWT se quebram — `alg: none` passa, ou uma chave pública RSA vira segredo de HMAC. - **`state`**: sem ele o retorno pode ser forjado por outro site (CSRF no login). - **`nonce`**: amarra o `id_token` a *este* pedido, contra replay. - **PKCE (S256)**: um código roubado no meio do caminho não serve sem o verificador. - **`iss`, `aud`, `exp`, `nbf`**: um token legítimo, mas emitido para outro app ou por outro tenant, não vale aqui. A tolerância de relógio é de 60 segundos. - **`next`**: só caminho do próprio app. `//evil.exemplo` e `https://evil.exemplo` viram `/` — redirecionamento aberto é o jeito clássico de dar credibilidade a um phishing. - **Chave desconhecida**: o JWKS é buscado de novo quando o provedor rotaciona a chave, mas no máximo uma vez por minuto, para que um token forjado não vire uma requisição de rede por requisição HTTP. Toda recusa vira `SecurityEvent` do tipo `auth` e entra em `trilha_security_events_total`, o contador do [capítulo de observabilidade](/trilha/pt/aprender/observabilidade). ## Desafio Seu app precisa de uma rota que só funcione para quem entrou **nos últimos cinco minutos** — uma reautenticação recente antes de uma operação sensível, como trocar a chave de API. :::solucao ```go func recente(c *trilha.Ctx, next trilha.Next) error { u := flow.User(c) if u == nil || time.Since(u.IssuedAt) > 5*time.Minute { return trilha.RedirectCode("/entrar?next="+url.QueryEscape(c.Request().URL.Path), 302) } return next() } ``` `IssuedAt` é o momento do login, e `Start` cria uma sessão nova a cada volta pelo provedor — então quem já estava logado só precisa passar de novo pela tela do provedor, que costuma aceitar sem pedir senha outra vez. Para forçar a digitação, acrescente `prompt=login` aos parâmetros de autorização. ::: --- # Interface com ui Source: /trilha/pt/aprender/interface-com-ui O kit de componentes padrão do Trilha, compatível com temas do shadcn/ui, e como ele fica seu para customizar. Todo projeto criado com `trilha new` já vem com o kit `ui`: componentes tipados em Go (`ui.Button`, `ui.Card`, `ui.Field`...) que renderizam classes de um CSS pequeno e prefixado (`ui-*`), mais um JavaScript de 200 linhas para o que o HTML não faz sozinho (abas, avisos que somem, campos condicionais, tema claro/escuro). Nenhuma dependência: os três arquivos ficam em `public/` e são seus. ```text public/ui.theme.css ← as cores e o raio: edite ou cole um tema pronto public/ui.css ← os componentes; `trilha ui` atualiza public/ui.js ← comportamentos; `trilha ui` atualiza ``` O contrato de tema é o do [shadcn/ui](https://ui.shadcn.com) (MIT): as mesmas variáveis, `--background`, `--primary`, `--radius`, em `oklch`. Gere um tema em ui.shadcn.com/themes ou tweakcn.com, cole o bloco `:root { … } .dark { … }` em `ui.theme.css` e pronto: nada em Go muda. O Trilha não usa React nem Tailwind; a compatibilidade é só do tema. ## Ligando o kit O layout gerado já faz isso; num projeto existente, rode `trilha ui` e adicione: ```go h.Head(…, ui.Head(c)), // ui.theme.css, ui.css, tema salvo, ui.js h.Body(ui.Body(), // fonte e cores do tema ui.Header(ui.Brand("/", "Meu app"), ui.Nav(ui.NavLink("/", "Início", true)), ui.Spacer(), ui.ThemeToggle()), h.Main(ui.Container(children)), ui.Flashes(c), // onde os avisos aparecem, o c.Flash junto ) ``` ## Variantes são atributos Um componente é uma função que devolve `h.Node`; variantes e tamanhos são atributos de classe que você mistura com qualquer atributo do `h`, na ordem que quiser. O `h` funde os `class` repetidos em um só. @demo ui-botoes ## Formulários `ui.Field` junta rótulo, controle, ajuda e erro com os `id`/`for` e o `aria-*` certos. `ui.ShowWhen("campo", "valor")` mostra o grupo só enquanto o campo tem aquele valor e **desabilita os controles escondidos**, para eles não irem no `POST`. Sem JavaScript, os campos simplesmente aparecem todos. @demo ui-formulario Depois de um `POST`, renderize o erro no próprio campo (`ui.Error("Título obrigatório")` + `ui.Invalid()` no controle) e um aviso que some sozinho: `ui.Toast("success", "Salvo!", 4000)` dentro do toaster do layout. O exemplo `examples/blog` faz as duas coisas em `app/blog/novo/page.go`. ## Contar o que aconteceu, e perguntar antes de destruir Um `POST` que deu certo termina em redirect, e o redirect come a notícia. O `c.Flash` escreve num cookie assinado, e o `ui.Flashes(c)` do layout mostra na página seguinte: ```go c.Flash(ui.FlashSuccess, "Post apagado") return c.Redirect("/blog") ``` Os tipos são `ui.FlashInfo`, `ui.FlashSuccess` e `ui.FlashError`. Numa resposta de fragmento não há redirect para sobreviver: os avisos vão num cabeçalho e quem mostra é o `ui.js` — a chamada no handler é a mesma. Sem `TRILHA_SECRET` nada é escrito, e o app avisa uma vez no log. Antes de algo irreversível, o `ui.Confirm` põe a pergunta no próprio formulário: ```go h.Form(h.Method("post"), h.Action("/blog/"+p.Slug), trilha.CSRFInput(c), ui.Confirm("Apagar este post?", "Não dá para desfazer."), h.Data("ui-confirm-cancel", "Cancelar"), ui.Submit(ui.Destructive(), h.Text("Apagar"))) ``` O `ui.js` segura o envio, abre o diálogo do kit e só então deixa passar. Sem JavaScript o formulário envia direto; quando isso não serve, pergunte numa página própria (`GET /blog/{slug}/apagar` renderizando o mesmo formulário), que funciona dos dois jeitos. ## Cards, abas, progresso @demo ui-card ## Diálogo e avisos `ui.Dialog` é um `<dialog>` nativo: fecha com Esc, clique fora ou `ui.DialogClose`; o formulário dentro dele faz `POST` normalmente. @demo ui-dialogo ## Tabelas com hierarquia `ui.Depth(n)` indenta a primeira célula: serve para plano de contas, árvore de categorias e qualquer *drill-down* renderizado no servidor. `ui.Num()` alinha números à direita. @demo ui-tabela ## Paginação e dicas `ui.Pagination` desenha a navegação de páginas com links de verdade, então uma página pode ser compartilhada, recarregada e indexada. A página atual é um `<span>` com `aria-current` — link para onde você já está é link para lugar nenhum — e a primeira página não tem *anterior*, então nada é desenhado no lugar. A janela guarda a primeira página, a última e as vizinhas da atual, com reticências sobre cada buraco, para o rodapé não crescer junto com a tabela. `ui.Tooltip` escreve a dica no `title`, que é o tooltip do próprio navegador e funciona com o `ui.js` desligado. Com o script na página o `title` some — dois tooltips é pior que nenhum —, uma bolha com `role="tooltip"` toma o lugar dele, o alvo ganha `aria-describedby` e a dica responde ao mouse, ao foco do teclado e ao toque, fechando com Escape. @demo ui-paginacao :::nota A dica é uma string de propósito. Dica com link dentro é *popover*, e para isso existe o `ui.Menu`. ::: ## Atualizar e customizar - `trilha ui` regrava `ui.css` e `ui.js` quando você atualiza o Trilha; nunca toca em `ui.theme.css`. Se você editou `ui.css`, ele avisa e só sobrescreve com `--force`. - Para mudar um componente, edite `ui.css` (ele é seu) ou sobreponha em `style.css`. Para um componente novo, escreva a função no seu pacote: `func Preco(v int) h.Node { return h.Span(h.Class("ui-badge preco"), …) }`. - Ícones: `ui.Icon("check")`, um conjunto pequeno do [Lucide](https://lucide.dev) (ISC). `ui.Icons()` lista os nomes. Para outros, cole o SVG num `h.Raw` seu. ## Desafio Faça um formulário de cadastro em que o campo "Empresa" só aparece quando "Tipo" é "Jurídica" e, ao enviar sem preencher, o erro apareça no campo e um aviso some após 3 s. :::solucao ```go func Page(c *trilha.Ctx) (h.Node, error) { erro := c.Query("erro") return h.Form(h.Method("post"), h.Class("ui-stack"), trilha.CSRFInput(c), ui.Field("tipo", "Tipo", ui.Select(h.ID("tipo"), h.Name("tipo"), h.Option(h.Value("pf"), h.Text("Física")), h.Option(h.Value("pj"), h.Text("Jurídica")))), ui.Field("empresa", "Empresa", ui.Input(h.ID("empresa"), h.Name("empresa"), h.If(erro != "", ui.Invalid())), ui.Error(erro), ui.With(ui.ShowWhen("tipo", "pj"))), ui.Submit(h.Text("Cadastrar")), h.If(erro != "", ui.Toaster(ui.Toast("error", erro, 3000))), ), nil } func POST(c *trilha.Ctx) error { if c.Form("tipo") == "pj" && strings.TrimSpace(c.Form("empresa")) == "" { return c.Redirect("/cadastro?erro=Empresa+obrigat%C3%B3ria") } return c.Redirect("/cadastro/ok") } ``` ::: --- # Interatividade Source: /trilha/pt/aprender/interatividade Trocar um pedaço da página e enviar formulário sem recarregar, com o mesmo handler que serve a página inteira. Uma página do Trilha é HTML inteiro: o navegador navega, o servidor responde, a tela pisca. Isso funciona bem, mas não em toda tela — filtrar uma lista ou salvar um formulário não deveria custar uma recarga. O caminho aqui é **fragmento**: o mesmo link e o mesmo formulário de sempre, com um atributo a mais. Com JavaScript ligado, o kit `ui` pede a página, o servidor devolve só o pedaço e o navegador troca aquele elemento. Sem JavaScript, o link navega e o formulário envia — o servidor devolve a página inteira porque ninguém pediu fragmento. Nenhuma rota nova, nenhum handler novo, nenhuma dependência. ## Uma pergunta a mais no handler `c.Fragment()` devolve o id que o cliente quer trocar, ou `""` numa navegação normal: ```go func Page(c *trilha.Ctx) (h.Node, error) { c.SetTitle("Clientes") return tela(c, c.Query("q")), nil } // tela é a página inteira quando não há fragmento, e o pedaço quando há: // o elemento trocado precisa carregar o mesmo id. func tela(c *trilha.Ctx, q string) h.Node { return h.Div(h.ID("lista"), h.Form(h.Method("get"), h.Action("/clientes"), ui.Swap("lista"), ui.Input(h.Name("q"), h.Value(q)), ui.Submit(h.Text("Buscar")), ), lista(clientes.Buscar(q)), ) } ``` Quando a requisição traz o cabeçalho `Trilha-Fragment`, o Trilha: - **pula os layouts** da rota (nada de `<html>`, `<head>`, barra de navegação); - escreve só os nós que você devolveu, sem o envelope do documento e sem o script do dev server; - responde com `Vary: Trilha-Fragment`, para um cache não guardar o pedaço no lugar da página. Tudo o mais continua igual: middleware roda, CSRF é verificado, o status é o que você mandou. `c.Fragment()` é só uma pergunta. ## O link e o formulário No HTML, `ui.Swap("id")` marca quem participa: ```go ui.ButtonLink("/clientes?pagina=2", ui.Swap("lista"), h.Text("Próxima")) h.Form(h.Method("post"), h.Action("/clientes"), ui.Swap("tela"), trilha.CSRFInput(c), // campos… ) ``` O `ui.js` intercepta o clique (só botão esquerdo, sem Ctrl/Cmd, mesma origem) e o envio, faz um `fetch` com o cabeçalho, e troca o elemento pelo HTML que voltou. Enquanto espera, o alvo ganha `aria-busy="true"` (o CSS do kit deixa o bloco opaco e o cursor de espera). `ui.NoPush()` no link evita mexer no histórico. ## Depois do POST Um `POST` que redireciona continua redirecionando — inclusive no fragmento. Como o `fetch` seguiria o 303 sozinho e devolveria a página nova em pedaço, o Trilha responde **204 com o cabeçalho `Trilha-Location`**, e o `ui.js` navega de verdade. O padrão redirecionar-depois-de-gravar sobrevive. Quando faz mais sentido ficar na mesma tela, responda com o pedaço atualizado: ```go func POST(c *trilha.Ctx) error { in, errs := ler(c) if len(errs) > 0 { return c.Render(422, tela(c, in, errs, "")) // formulário com os erros } clientes.Criar(in) if c.Fragment() != "" { return c.Render(200, tela(c, clientes.Cliente{}, nil, "Cadastro salvo!")) } return c.Redirect("/clientes?ok=1") } ``` Em **422** o `ui.js` põe o foco no primeiro campo com `aria-invalid="true"` — é o que o navegador faria sozinho numa recarga. Fora disso, ele devolve o foco (e a posição do cursor) ao campo que estava em uso, procurando pelo `id` ou pelo `name`. ## Quando o fragmento não dá certo O kit **nunca deixa a tela travada**: se a resposta for 5xx, se a rede cair ou se o pedaço vier sem o id esperado, ele desiste e faz a navegação de verdade — o link vira `location`, o formulário vira `form.submit()`. O usuário vê a página recarregar; não vê um clique que não fez nada. ## Depois da troca Elementos novos entram já hidratados: `[data-ui-fade]` e `[data-ui-show-when]` voltam a funcionar sozinhos. Se você tem comportamento próprio, ouça o evento: ```js document.addEventListener("trilha:swap", (e) => { // e.detail.target = elemento novo, e.detail.status = status da resposta }); ``` `window.ui.swap(id, html, status)` e `window.ui.hydrate(el)` estão expostos para quem precisar fazer a troca à mão. ## A ilha: o que o fragmento não faz Fragmento vem sempre do servidor. Um editor com prévia ao vivo, um canvas, um mapa que arrasta: o estado está no cliente e não há ida e volta a fazer. Isso é uma **ilha** — um pedaço da página que traz o próprio módulo, com tudo em volta continuando HTML comum. ```go c.Island("/editor.js", map[string]any{"ppm": 200}, h.Class("editor"), ui.Textarea(h.Name("corpo")), // o conteúdo de origem: ainda é um campo h.P(h.Data("info", ""), h.Hidden()), // preenchido pelo módulo ) ``` ```html <div data-trilha-island="/editor.js?v=9c1f" data-trilha-props="{"ppm":200}" class="editor">…</div> ``` O módulo é um ES module comum em `public/`, e a exportação padrão dele é a montagem: ```js export default function (el, props) { const area = el.querySelector("textarea"); area.addEventListener("input", () => { /* … */ }); } ``` Quatro coisas saem desse formato: - **Os filhos são o conteúdo de origem, e quem os renderiza é o servidor.** Script bloqueado, ainda a caminho ou 404: a página é o que sempre foi. A ilha acrescenta, não sustenta. - **As props são dado.** Vão escapadas num atributo e voltam pelo `JSON.parse` — um valor vindo do banco não vira marcação. Vale o que o `encoding/json` serializa; o que não serializa avisa no log e deixa o conteúdo de origem em paz. - **Sem bundler e sem hidratação global.** O módulo é um arquivo em `public/`, endereçado pelo `Asset` (então a URL leva o hash do conteúdo), e só as ilhas presentes na página são montadas, cada uma uma vez. O carregador é um único script inline com o nonce da requisição, e é por isso que a CSP padrão o aceita sem `unsafe-inline`. - **Uma ilha que chega dentro de um fragmento também monta**: o carregador ouve o `trilha:swap`. O que ele precisa é já estar na página — ou seja, a página renderizou ao menos uma ilha própria. ### A porta de saída A ilha é a fronteira onde outra biblioteca entra, e onde o custo dela para. Web Components não precisam de nada daqui — `customElements.define` e a tag é a ilha. Para Alpine, htmx ou o que for, ponha o arquivo em `public/` e importe do módulo da ilha; para React, uma build ESM em `public/` e um `createRoot(el)` dentro da montagem. A página em volta não é obrigada a virar componente, e o resto do projeto não fica sabendo da escolha. A CSP padrão é `script-src 'self'`, então módulo vindo de CDN é recusado até você abrir a mão — decisão, não acidente. ## A página inteira, sem a recarga O fragmento troca um pedaço da página que um handler escolheu. A navegação é a outra metade: a próxima página é *outra* página, e o que não deveria piscar é tudo em volta — o cabeçalho, a barra lateral, a rolagem de uma lista longa. ```go // app/painel-/layout.go return h.Section(h.Class("app"), ui.Navigate("conteudo"), ui.NavigateScript(c), ui.Sidebar(ui.Nav( ui.NavLink("/painel", "Painel", cur == "/painel"), ui.NavLink("/relatorio", "Relatório", cur == "/relatorio"), )), h.Div(h.Class("app-content"), children), ), nil ``` `ui.Navigate(id)` marca uma região: um clique em link da mesma origem dentro dela busca a próxima página e troca o `#id` pelo mesmo elemento dela. `ui.NavigateScript(c)` carrega o comportamento — arquivo separado do `ui.js`, para que um app que não navegue assim não o baixe. No servidor não muda nada: `/relatorio` é a mesma rota, respondendo o mesmo documento. Recarregar, abrir em outra aba ou chegar com o JavaScript desligado dá a mesma página. Desligada por padrão, e desligada por link: ```go ui.ButtonLink("/relatorio.pdf", ui.NoNavigate(), h.Text("Baixar")) ``` O navegador mantém os costumes — Voltar e Avançar funcionam e restauram a rolagem da entrada para onde voltam, `Cmd`-clique abre aba, `target` e `download` passam intactos. O kit acrescenta `aria-busy` durante a espera, leva o foco para o que entrou e dispara `trilha:swap`, então uma ilha dentro da página nova monta. Um segundo clique cancela a primeira requisição; 5xx, redirecionamento ou página sem aquele id desiste e navega de verdade. A regra de bolso: **fragmento** quando um handler responde um pedaço, **navegação** quando a resposta é uma página e a moldura em volta deve ficar. ## O arquivo, e a barra que diz onde ele está Mandar arquivo é o único lugar onde "a tela pisca" não é o problema — o problema é não acontecer nada por trinta segundos. O navegador sabe quanto já subiu; ele só não tem como dizer isso num envio de formulário comum. ```go // app/anexos/page.go h.Form(h.Method("post"), h.Action("/anexos"), h.Enctype("multipart/form-data"), ui.UploadTo("lista"), trilha.CSRFInput(c), ui.Field("arquivo", "Arquivo", ui.Input(h.ID("arquivo"), h.Name("arquivo"), h.Type("file"), h.Required())), ui.UploadBar(), ui.Submit(h.Text("Enviar")), ) ``` `ui.UploadTo(id)` envia o formulário por XHR e troca o `#id` pela resposta; `ui.UploadBar()` é o `<progress>` que o kit preenche com o evento de progresso do próprio navegador; e `ui.UploadScript(c)` carrega o comportamento — arquivo próprio de novo, para que uma página sem upload não o baixe. Com o JavaScript desligado nada disso existe, e o formulário é o que sempre foi: envia, o servidor responde, a página recarrega. No servidor não há API nova. A requisição leva o `Trilha-Fragment`, então o mesmo handler que desenha a página responde o pedaço: ```go func POST(c *trilha.Ctx) error { if err := c.FormErr(); err != nil { return err } f, hdr, err := c.Request().FormFile("arquivo") if err != nil { return err } defer f.Close() anexos.Add(hdr.Filename, hdr.Size) if c.Fragment() != "" { return c.Render(200, lista()) // o pedaço, com o mesmo id } return c.Redirect("/anexos") // sem JavaScript: gravar, redirecionar, buscar } ``` ### O limite é do app; a exceção é da rota O corpo tem teto no `Config.MaxBodyBytes` (1 MiB por padrão) — é esse teto que impede uma requisição de comer a memória do servidor, e ele deve continuar de pé em toda rota que recebe formulário. A rota que recebe arquivo diz isso por conta própria, no `middleware.go` dela: ```go // app/anexos/middleware.go func Middleware(c *trilha.Ctx, next trilha.Next) error { if c.Request().Method == "POST" { c.AllowBody(8 << 20) c.NoReadDeadline() // conexão ruim não é erro } return next() } ``` No middleware, e não no handler: o CSRF lê o formulário antes do handler rodar, então lá dentro o corpo já teria sido lido no limite antigo. O resto do app continua no 1 MiB, e passar dos 8 MiB continua sendo 413 com a mensagem de sempre. ## O que isso não é Não é SPA. Não há roteador no cliente, estado compartilhado, hidratação de componente nem *diff* de DOM — a troca é `outerHTML`, e a fonte da verdade continua sendo o servidor. Uma tela que precise de estado local rico (um editor, um canvas) merece JavaScript próprio, e a ilha acima é onde esse JavaScript mora; o fragmento resolve o caso comum, que é a maioria das telas. Vale lembrar o limite de segurança: o cabeçalho `Trilha-Fragment` é personalizado, então um site de terceiros não consegue mandá-lo sem *preflight* — e o Trilha não responde *preflight*. Um fragmento só sai para a sua própria origem. O exemplo `examples/cadastro` usa os dois: busca que filtra a lista e cadastro que salva sem recarregar, ambos funcionando com o JavaScript desligado. ## Desafio Faça a lista trocar sozinha enquanto o usuário digita, sem esperar o botão — e sem disparar uma requisição por tecla. :::solucao ```js let t; document.addEventListener("input", (e) => { const campo = e.target.closest("form[data-trilha-target] input[name=q]"); if (!campo) return; clearTimeout(t); t = setTimeout(() => campo.form.requestSubmit(), 250); }); ``` `requestSubmit()` dispara o mesmo evento `submit` que o kit já escuta, então o `data-trilha-target` continua valendo — e o formulário segue funcionando no clique do botão para quem não tem JavaScript. ::: --- # IA e agentes Source: /trilha/pt/aprender/ia-e-agentes Chame um modelo, dê ferramentas a um agente, transfira a conversa entre agentes, use e exponha MCP, e transmita a resposta em streaming. O pacote `ai` fala o protocolo de *chat completions* da OpenAI, que hoje é a língua franca dos provedores: OpenAI, Groq, Mistral, OpenRouter, Ollama, LM Studio e vLLM aceitam as mesmas requisições. Você configura a URL e o modelo por variáveis de ambiente e o código não muda. Como todo o Trilha, `ai` e `ai/mcp` não trazem dependências fora da biblioteca padrão. ```bash export OPENAI_API_KEY=sk-... # ou qualquer token do seu provedor export OPENAI_BASE_URL=http://localhost:11434/v1 # Ollama local, por exemplo export TRILHA_AI_MODEL=qwen2.5:7b ``` ## Uma chamada ```go cli := ai.NewFromEnv() resp, err := cli.Chat(ctx, ai.Request{Messages: []ai.Message{ ai.System("Responda em uma frase."), ai.User("O que é um layout no Trilha?"), }}) fmt.Println(resp.Text()) ``` `Stream` entrega a resposta aos pedaços; `Delta.Content` traz o texto e `Delta.ToolCalls` os argumentos de ferramentas conforme chegam. ## Ferramentas Uma ferramenta é um nome, uma descrição, um JSON Schema para os argumentos e uma função Go. `ai.Typed` decodifica os argumentos em uma struct para você: ```go clima := ai.NewTool("clima", "Temperatura atual em uma cidade.", ai.Schema(`{"type":"object","properties":{"cidade":{"type":"string"}},"required":["cidade"]}`), ai.Typed(func(ctx context.Context, in struct{ Cidade string }) (string, error) { return buscarTemperatura(ctx, in.Cidade) })) ``` Erros e pânicos dentro da ferramenta viram texto para o modelo ("error: ..."), nunca derrubam o servidor. O modelo lê o erro e decide o que fazer, que é o comportamento que você quer em um agente. ## Agentes Um agente é instruções + ferramentas. `ai.Run` executa o laço modelo → ferramentas → modelo até a resposta final (ou `MaxTurns`, padrão 10). Chamadas de ferramentas na mesma rodada rodam em paralelo. ```go assistente := &ai.Agent{ Name: "Assistente", Instructions: "Responda em português, de forma curta.", Tools: []*ai.Tool{clima}, } res, err := ai.Run(ctx, cli, assistente, "Está frio em Curitiba?") fmt.Println(res.Output) // texto final fmt.Println(res.Steps) // cada ferramenta chamada, com argumentos e saída ``` `res.Messages` é a conversa inteira; passe-a como histórico na próxima chamada para manter o contexto: `ai.Run(ctx, cli, assistente, "E amanhã?", res.Messages...)`. ## Multiagentes Três formas de compor agentes, da mais simples à mais controlada: - **Handoff**: `Handoffs: []*ai.Agent{tradutor}` cria a ferramenta `transfer_to_tradutor`. Quando o modelo a chama, o tradutor assume a conversa: as instruções trocam, o histórico fica. É o padrão "triagem → especialista". - **Agente como ferramenta**: `pesquisador.AsTool(cli, "Pesquisa um tema")` faz o agente principal chamar o outro como uma função e continuar ele mesmo a conversa. - **Orquestração em Go**: `ai.Parallel` roda vários agentes de uma vez e `ai.Chain` passa a saída de um como entrada do próximo. Você fica com o controle no código, sem depender de o modelo "lembrar" de delegar. ## Streaming até o navegador `c.Stream()` transforma a resposta em Server-Sent Events e `ai.RunStream` entrega os eventos do agente (texto, chamada de ferramenta, resultado, handoff, fim): ```go func POST(c *trilha.Ctx) error { var in struct{ Message string; History []ai.Message } if err := c.BindJSON(&in); err != nil { return err } s := c.Stream() _, err := ai.RunStream(c.Context(), cli, assistente, in.Message, func(ev ai.Event) { switch ev.Type { case "text": _ = s.Send("text", ev.Text) case "done": _ = s.JSON("done", map[string]any{"history": ev.Result.Messages}) } }, in.History...) return err } ``` No cliente, um `fetch` com `POST` e leitura do corpo com `ReadableStream` basta (o `EventSource` do navegador só faz `GET`). O exemplo `examples/assistente` traz o `chat.js` completo em 60 linhas. ## MCP: usar e expor ferramentas O *Model Context Protocol* padroniza como hosts (Claude, Cursor, VS Code...) descobrem e chamam ferramentas. O Trilha implementa os dois lados. **Cliente**: as ferramentas de qualquer servidor MCP viram `*ai.Tool` para os seus agentes. ```go fs, err := mcp.Dial(ctx, mcp.Stdio("npx", "-y", "@modelcontextprotocol/server-filesystem", ".")) tools, err := fs.Tools(ctx) agente.Tools = append(agente.Tools, tools...) ``` `mcp.HTTP(url, headers)` conecta a servidores remotos (Streamable HTTP). **Servidor**: as ferramentas do seu app ficam disponíveis para hosts externos com uma rota: ```go // app/mcp/route.go var servidor = mcp.NewServer("meu-app", "1.0", clima, buscarPedido) func POST(c *trilha.Ctx) error { return servidor.ServeHTTP(c) } ``` Proteja a rota como qualquer API (middleware com token, limite de taxa). O servidor emite `Mcp-Session-Id` no `initialize` e rejeita mensagens sem sessão. Para hosts que preferem stdio, `servidor.ServeStdio(ctx, os.Stdin, os.Stdout)` em um `main` separado. ## Seu projeto explicado para um agente Os capítulos acima falam do agente que a sua aplicação executa. Esta seção fala do agente que edita a sua aplicação — Claude Code, Cursor, Copilot — e do arquivo que ele lê primeiro. ```bash trilha agents # num projeto que já existe trilha new loja --agents # já na criação ``` O `--agents` é flag do `new`; num projeto que já existe o comando é o `trilha agents`, e a atualização vinda de uma versão anterior tem cinco linhas na [receita de migração](/trilha/pt/receitas/migracao#ligar-os-arquivos-de-agente-num-projeto-que-ja-existe). Ele grava dois arquivos na raiz. O `AGENTS.md` é do framework: as três convenções, os comandos e o que cada um verifica, e o que não fazer (editar `trilha_gen.go`, acrescentar dependência, pôr segredo no código). O `CLAUDE.md` é seu: três linhas apontando para o `AGENTS.md` e espaço para o que este repositório precisar. Nenhum dos dois existe sem que você peça. Suporte a agentes é escolha do time, não convenção do framework, então `trilha new` sozinho deixa seu projeto exatamente como deixava antes. O `AGENTS.md` é atualizado como o kit ui: ele leva o hash do próprio corpo, então uma cópia intocada de uma versão anterior é regravada em silêncio e uma que você editou pede `--force`. Acrescente suas regras nele e elas sobrevivem à próxima atualização — o comando recusa em vez de sobrescrever. Dois comandos existem para esse leitor em particular. O `trilha ctx` imprime o mapa do projeto — cada rota com seu arquivo e seus métodos, cada operação de API com o que recebe e o que devolve, os tipos envolvidos, o que o `app/setup.go` provê — numa leitura só, em vez de uma dúzia de arquivos abertos, com `--json` quando quem lê é uma ferramenta. O `trilha check` é o portão único antes de dizer que terminou: `gen`, `gofmt`, `vet`, `test`, `audit` e `openapi` num comando, parando na primeira falha, com `--fix` para os dois problemas que ninguém devia precisar ouvir duas vezes. Todo problema que ele reporta vem com o arquivo, a linha e a frase que resolve, então descobrir isso não custa uma ida e volta a mais. Os dois estão na [CLI](/trilha/pt/referencia/cli#trilha-check). :::note Esta documentação também é publicada em texto puro, muito mais barato para um agente ler do que o HTML em volta: o [/pt/llms.txt](/trilha/pt/llms.txt) é o índice, uma linha por página, e o [/pt/llms-full.txt](/trilha/pt/llms-full.txt) é tudo concatenado, blocos de código inclusive. ::: ## Desafio Dê ao agente do exemplo uma ferramenta `buscar_post` que consulte a API do blog (`/api/posts/{id}`) e peça: "resuma o post ola-trilha". :::solucao ```go buscarPost := ai.NewTool("buscar_post", "Busca um post do blog pelo slug.", ai.Schema(`{"type":"object","properties":{"slug":{"type":"string"}},"required":["slug"]}`), ai.Typed(func(ctx context.Context, in struct{ Slug string }) (string, error) { p, ok := posts.BySlug(in.Slug) if !ok { return "", fmt.Errorf("post não encontrado: %s", in.Slug) } return p.Title + "\n\n" + p.Body, nil })) assistente.Tools = append(assistente.Tools, buscarPost) ``` Por ser uma chamada em processo, não há HTTP nem chave: a ferramenta lê o repositório direto. Quando a fonte é externa, use o `ctx` para respeitar o cancelamento do cliente. ::: --- # Exemplos Source: /trilha/pt/aprender/exemplos Apps completos em examples/, do básico ao complexo, e o que cada um ensina. Os exemplos são apps de verdade, com testes de integração que rodam no `make test` do repositório. Cada um tem um `README.md` curto. Rode qualquer um com `trilha dev` dentro da pasta (ou `go run ../../cmd/trilha dev` a partir do clone). :::nota Os apps de exemplo são escritos em português: nomes de pasta, identificadores e textos de interface. O código é o mesmo Trilha que você lê aqui; a documentação em inglês avisa a mesma coisa para quem chega por lá. ::: | Nível | Pasta | O que ensina | |---|---|---| | Básico | `examples/blog` | todas as convenções de arquivo, layouts aninhados, grupos de rota, API JSON, middleware, sessão assinada, `tmpl` | | Médio | `examples/cadastro` | formulário com regras: campos condicionais, validação no servidor com erros por campo, seleção dependente, aviso que some, layout responsivo | | Complexo | `examples/orcamento` | domínio em árvore (plano de contas), agregação, drill-down por rota dinâmica, componentes aninhados e recursivos, diálogo com formulário, filtro por período, CSV | | SSO | `examples/sso` | login OpenID Connect com Entra ID ou Keycloak, área protegida, papel exigido, logout federado | | IA | `examples/assistente` | chat em streaming, agente com ferramentas, handoff, servidor MCP | ## Médio: cadastro O modelo do formulário é uma struct com tags `form`; `c.Bind(&in)` a preenche (structs aninhadas são achatadas, com prefixo opcional): ```go type Cliente struct { Tipo string `form:"tipo"` Nome string `form:"nome"` Endereco Endereco // cep, rua, uf, cidade Cobranca Endereco `form:"cob_"` // cob_cep, cob_rua... Novidades bool `form:"novidades"` } ``` A validação é uma função pura que devolve `trilha.FieldErrors`, e o `POST` decide: ```go func POST(c *trilha.Ctx) error { var in clientes.Cliente if err := c.Bind(&in); err != nil { return err // conversão inválida → 422 } clientes.Normalizar(&in) // ignora o que o tipo não usa if errs := clientes.Validar(in); errs.Any() { return c.Render(422, tela(c, in, errs)) // mesma página, com layouts } clientes.Salvar(in) return c.Redirect("/?ok=1") // PRG + aviso que some } ``` Na tela, cada campo lê o valor e o erro do mesmo lugar: ```go ui.Field("cnpj", "CNPJ", ui.Input(h.ID("cnpj"), h.Name("cnpj"), h.Value(in.CNPJ), ui.InvalidIf(errs, "cnpj")), ui.Errors(errs, "cnpj")) ``` Os grupos condicionais usam `ui.ShowWhen("tipo", "pj")`: escondidos ficam desabilitados e não vão no `POST`; e como alguém pode montar o `POST` à mão, `Normalizar` zera o que o tipo não usa antes de validar. O `<select>` de cidade é preenchido por `GET /api/cidades?uf=` com 20 linhas de `app.js`; no 422 o servidor já devolve as cidades da UF escolhida, então a página volta completa sem JavaScript. ## Complexo: orçamento O plano de contas é uma árvore (`Conta{Codigo, Nome, Filhos}`); orçado e realizado de uma conta sintética são a soma das filhas, calculados na leitura. Os componentes espelham a árvore: `Linha` renderiza a conta e chama a si mesma para as filhas, `ui.Depth(n)` indenta: ```go func Linha(c *plano.Conta, mes string, nivel, max int) h.Node { row := h.Tr(ui.Depth(nivel), h.Td(h.A(h.Href("/contas/"+c.Codigo), h.Text(c.Nome))), ...) if nivel >= max || c.Analitica() { return row } return h.Fragment(row, h.Map(c.Filhos, func(f *plano.Conta) h.Node { return Linha(f, mes, nivel+1, max) })) } ``` O drill-down é a rota `app/contas/codigo_/page.go`: breadcrumb com `Caminho()`, filhas (mesma `Tabela`) ou lançamentos (conta analítica). O formulário de lançamento é **um só** (`FormLancamento`), usado dentro de `ui.Dialog` na visão geral e no drill-down, e sozinho em `/lancamentos`; o `POST` valida com `c.Bind` + `plano.Validar` e, no 422, `app.js` reabre o diálogo porque encontrou `.ui-field-error` dentro dele. `voltar` (campo oculto) diz para onde redirecionar no sucesso. A exportação fica em `app/api/relatorio.csv/route.go`, uma pasta com ponto no nome. ## SSO: Entra ID e Keycloak O `examples/sso` é o fluxo de login inteiro em três rotas de duas linhas cada. O pacote `auth` cuida de PKCE, `state`, `nonce`, troca do código e validação do `id_token`; o app só encaminha: ```go // app/entrar/route.go var Kind = trilha.KindPage func GET(c *trilha.Ctx) error { return sso.Start(c) } ``` Proteger uma subárvore é um `middleware.go`, como qualquer outro: ```go // app/painel/middleware.go func Middleware(c *trilha.Ctx, next trilha.Next) error { return sso.Require(c, next) } // app/painel/relatorio/middleware.go — papel, não só sessão func Middleware(c *trilha.Ctx, next trilha.Next) error { return sso.RequireAdmin(c, next) } ``` Abaixo do middleware, a página lê `sso.User(c)` sem checar nada. Um navegador anônimo é mandado para `/entrar?next=…`; uma chamada de `/api` recebe 401 em JSON, porque redirecionar um cliente HTTP para um formulário só produz um erro de parsing confuso. Nenhum segredo mora no código: o provedor vem de variáveis de ambiente, e sem elas o app sobe assim mesmo e diz o que falta. ## O que virou framework Escrever os dois exemplos mostrou repetição que agora é API: `c.Bind`, `trilha.FieldErrors`, `c.Render` (página com layouts a partir de um `POST`), `ui.Errors`, `ui.InvalidIf`, `ui.SelectOptions`, `ui.Checked`. É o critério da constituição: um exemplo que precisa de código repetitivo indica uma lacuna no Trilha, não no exemplo. ## Desafio No orçamento, adicione uma coluna "Ano" ao drill-down que some os doze meses da conta. :::solucao ```go func Ano(c *plano.Conta, ano string) (orcado, real int64) { for m := 1; m <= 12; m++ { mes := fmt.Sprintf("%s-%02d", ano, m) orcado += plano.Orcado(c, mes) real += plano.Realizado(c, mes) } return } ``` Chame-o em `Linha` e acrescente as duas células; como a agregação é recursiva, a coluna já funciona para contas sintéticas. ::: --- # Testes Source: /trilha/pt/aprender/testes Um cliente de teste no próprio framework: um pedido, uma sessão inteira, CSRF que já passa. Um app feito com Trilha é um `http.Handler`, então sempre deu para testá-lo com `httptest` e mais nada. O problema é o que vem antes da primeira asserção: um cliente, um pote de cookies e o token do CSRF copiado do cookie para o campo do formulário. São cinquenta linhas que todo projeto escreve de novo — e erra na primeira tentativa, porque o duplo envio só passa quando o cookie volta no pedido. O framework já emite esse cookie e já confere esse token, então ele traz o cliente junto. Sem framework de teste externo, sem biblioteca de asserção: o `package trilha` nunca importa `testing`. ## Um pedido ```go func TestListaPosts(t *testing.T) { res := trilha.TestRequest(t, newApp(), "GET", "/api/posts") res.WantStatus(200).WantContains(`"slug"`) } ``` `newApp()` é a função que o gerador escreve no `trilha_gen.go`: o mesmo app que serve em produção. O pedido passa pelo caminho de verdade — mux, middlewares, layouts, CSRF, negociação de erro — e o que volta é a resposta gravada. As asserções encadeiam e nenhuma devolve `error`. Em teste, o valor de um erro é parar com a mensagem certa, então a falha imprime o status, o alvo e o corpo: ```text GET /api/posts: status = 500, want 200 {"status":500,"title":"Internal Server Error","request_id":"…"} ``` ## Uma sessão inteira Quando o teste é um fluxo — abrir o formulário, enviar, seguir o redirecionamento — o cliente guarda os cookies que o app põe: ```go func TestPublicar(t *testing.T) { c := trilha.NewTestClient(t, newApp()) c.Get("/blog/novo").WantStatus(200) res := c.PostForm("/blog/novo", url.Values{"titulo": {"Olá"}}) res.WantStatus(303).WantHeader("Location", "/blog/ola") c.Get("/blog/ola").WantContains("Olá") } ``` `Get`, `PostForm` e `PostJSON` são atalhos do `Request`, que aceita qualquer método. O redirecionamento não é seguido sozinho: o teste que quer o destino pede o destino, porque onde um `303` para é uma asserção, não um detalhe. ## O CSRF passa por padrão Todo pedido enviado pelos auxiliares leva o cookie do CSRF, e todo método com corpo leva o mesmo valor no cabeçalho `X-CSRF-Token`. :::note Isso não é uma brecha na proteção. O duplo envio pede ao navegador que prove que consegue ler o próprio cookie, e o cliente de teste prova exatamente isso: cookie e token vêm do mesmo lugar. O que a conferência recusa — um formulário enviado de outro site, que não consegue ler o cookie — continua recusado. ::: Um teste que queira provar a recusa pede a recusa: ```go c.PostForm("/blog/novo", form, trilha.WithoutCSRF()).WantStatus(403) ``` ## Sessão assinada sem passar pelo login `WithSigned` grava um cookie assinado com o signer do próprio app — o mesmo que o `c.SetSigned` do handler usa. A página do administrador deixa de exigir um `POST /login` antes de cada caso: ```go res := trilha.TestRequest(t, newApp(), "GET", "/admin", trilha.WithSigned("sessao", "ana")) res.WantStatus(200) ``` A assinatura é de verdade: uma sessão forjada à mão continua falhando, que é para isso que serve o `trilha.WithCookie("sessao", "ana|9999999999|assinatura-falsa")` quando o que você quer testar é a recusa. ## Um `route.go`, uma página O `TestRoute` monta um app descartável em `Dev` em volta de uma rota só, então dá para testar o handler onde ele mora, antes de estar registrado em qualquer lugar: ```go res := trilha.TestRoute(t, trilha.Route{ Pattern: "/api/itens/{id}", Methods: map[string]trilha.HandlerFunc{"GET": GET}, }, "GET", "/api/itens/7") res.WantStatus(200).WantContains(`"id":7`) ``` É o padrão que resolve o `{id}`, então o `c.Param("id")` responde `7` — quem faz o trabalho é o roteador, não um dublê. O `TestPage` faz o mesmo por uma página e ainda devolve o nó renderizado, com os layouts já aplicados: ```go res := trilha.TestPage(t, trilha.Route{Page: Page, Layouts: []trilha.LayoutFunc{Layout}}, "/sobre") res.WantStatus(200) if h.Render(res.Node) == "" { t.Fatal("página vazia") } ``` O `res.Body` tem o documento inteiro, com o layout em volta; o `res.Node` é só o que a página devolveu. Asserção no nó sobrevive a uma troca de layout, que costuma ser o que você quer. Os dois montam o app para você; `trilha.WithApp(a)` usa o seu, quando a rota depende de algo que o `Setup` proveu com `trilha.Provide` — `trilha.Use[T](a)` lê de volta no próprio teste. ## As opções | Opção | O que faz | |---|---| | `WithApp(a)` | usa o seu app no `TestRoute`/`TestPage` em vez de um descartável | | `WithHeader(nome, valor)` | um cabeçalho (`Accept`, `Trilha-Fragment`, `Authorization`) | | `WithCookie(nome, valor)` | um cookie cru | | `WithSigned(nome, valor)` | um cookie assinado pelo app, válido por uma hora | | `WithForm(values)` | corpo em `application/x-www-form-urlencoded` | | `WithJSON(v)` | corpo em `application/json` | | `WithBody(contentType, corpo)` | corpo exatamente como escrito (multipart, CSV, um JSON quebrado) | | `WithoutCSRF()` | não manda nada de CSRF, para testar a recusa | ## A resposta O `TestResponse` embute o `*httptest.ResponseRecorder`, então `Code`, `Body` e `Header()` continuam à mão para o que as asserções prontas não cobrirem. | Método | O que faz | |---|---| | `WantStatus(código)` | falha com o corpo quando o status é outro | | `WantContains(texto)` | falha com o corpo quando o texto não está lá | | `WantHeader(nome, valor)` | falha quando o cabeçalho é outro | | `JSON(&v)` | decodifica o corpo em `v`, falhando com o corpo se o JSON for inválido | | `Cookie(nome)` | o cookie que esta resposta pôs, ou `nil` | | `Node` | o nó da página, preenchido pelo `TestPage` | O `Cookie` é como se faz a asserção de uma saída: o que prova que a sessão acabou é o app apagar o cookie, não o redirecionamento que vem depois. ```go if res.Cookie("sessao") == nil { t.Fatal("sair devia limpar a sessão") } ``` ## Corrida e fuzzing Dois defeitos nunca aparecem numa suíte determinística. Um é a corrida de dados: dois pedidos mexendo no mesmo campo do app ao mesmo tempo — o cache de assets, os contadores da métrica, os baldes do limite de taxa. O outro é a entrada que ninguém escreveu: um caminho com `%2e%2e`, um cookie com a assinatura de outra chave, um corpo de formulário que é só um `;`. A suíte do framework cobre os dois, e cada comando é uma linha: ```bash make race # go test -race ./... make fuzz # 20s em cada alvo de fuzzing, o mesmo do CI ``` O `make race` só vale o que a suíte der para ele olhar, então existe um teste (`TestConcorrencia`) que bate no mesmo app com 32 goroutines: entra, lê uma página assinada, chama uma rota de API, pede um arquivo estático e lê o `/metrics`. Sem ele o detector passaria por um app respondendo um pedido por vez e não acharia nada. Os alvos de fuzzing afirmam uma invariante, não uma saída esperada: | Alvo | O que ele sustenta | | --- | --- | | `FuzzRouteMatch` | nenhum alvo derruba o app nem serve arquivo de fora do `public/` | | `FuzzBindForm` / `FuzzBindJSON` | se o `Bind` não devolve erro, toda regra do `validate` vale | | `FuzzSignedVerify` | um cookie só é aceito se alguma chave o teria produzido, e enquanto não vence | | `FuzzParseTraceparent` | o id do trace ou é vazio ou é hexadecimal que veio do cabeçalho | | `FuzzRenderEscapes` | o que entra num `h.Text` ou num atributo volta escapado | Fazer fuzzing no seu app tem a mesma forma. Escreva o alvo ao lado do código que ele testa, semeie com os casos que você já conhece e afirme a propriedade — não a saída: ```go func FuzzSlug(f *testing.F) { for _, s := range []string{"", "Olá mundo", "a//b", "---"} { f.Add(s) } f.Fuzz(func(t *testing.T, s string) { got := Slug(s) if strings.ContainsAny(got, " /?#") { t.Fatalf("%q gerou %q", s, got) } }) } ``` :::note Quando o fuzzing acha uma falha, o Go grava a entrada em `testdata/fuzz/<Alvo>/`. Commite esse arquivo junto com a correção: dali em diante o `go test ./...` o repete, e o defeito não volta em silêncio. ::: ## Desafio Escreva um teste provando que o formulário do blog recusa um título maior que o limite e mostra a mensagem na página, sem passar pela API. :::solucao ```go func TestTituloLongo(t *testing.T) { c := trilha.NewTestClient(t, newApp()) res := c.PostForm("/blog/novo", url.Values{"titulo": {strings.Repeat("a", 200)}}) res.WantStatus(422).WantContains("no máximo") } ``` O formulário responde `422` com a página redesenhada — o mesmo corpo que o navegador mostraria —, então um pedido cobre a validação e a mensagem. O token do CSRF foi junto sozinho. ::: --- # Desenvolvimento e produção Source: /trilha/pt/aprender/dev-e-producao O que trilha dev faz por baixo, como publicar um binário e como configurar por variáveis de ambiente. ## `trilha dev` O comando escuta em `:3000` e roda o seu app em uma porta interna, encaminhando as requisições. A cada arquivo salvo: 1. regenera `trilha_gen.go` se a árvore de `app/` mudou; 2. recompila com `go build`; 3. sobe o processo novo, espera ele responder e só então derruba o antigo; 4. avisa o navegador por um evento (SSE), que recarrega. Mudanças só em `public/` pulam os passos 1 a 3. Um erro de compilação vira uma página com a saída do `go build`; corrija e ela some. O processo do app roda com `TRILHA_ENV=dev`, o que liga stack traces nas páginas de erro e desliga o cache de estáticos. ## O inspetor de rotas Enquanto o `trilha dev` roda, `http://localhost:3000/_trilha/routes` responde com o mapa do app: cada rota na ordem em que o roteador decide, com o tipo, os métodos, a pasta de origem, os layouts que a embrulham (de fora para dentro) e os middlewares que rodam antes dela — as duas coisas que o `trilha routes` não mostra, porque são cadeias, não linhas. A caixa no topo responde à pergunta que costuma levar alguém até lá: digite `/blog/ola` e a página diz qual padrão atende e quanto vale cada parâmetro. A resposta sai de um `http.ServeMux` montado com os seus padrões, então é o roteador decidindo, não uma segunda implementação das regras de precedência. Quem serve a página é o supervisor do dev, não o seu app: ela não está no binário que o `trilha build` produz, e a mesma URL em produção é um 404 como outro qualquer. Não há o que desligar antes de publicar. ## `trilha build` ```bash trilha build # → bin/agenda TRILHA_ENV=prod PORT=8080 ./bin/agenda ``` O binário é estático (`CGO_ENABLED=0`), tem `public/` embutido e não precisa da CLI nem de nenhum arquivo ao lado. Um `Dockerfile` cabe em quatro linhas: ```text FROM golang:1.25 AS build WORKDIR /src COPY . . RUN go run github.com/emersonjoe/trilha/cmd/trilha@latest build -o /app FROM gcr.io/distroless/static COPY --from=build /app /app ENV PORT=8080 CMD ["/app"] ``` ## Variáveis de ambiente | Variável | Efeito | |---|---| | `PORT` ou `ADDR` | porta ou endereço de escuta (padrão `:3000`) | | `TRILHA_ENV` | `dev` ou `prod` (padrão `prod`) | | `TRILHA_BASE_PATH` | prefixo de URL quando o app vive em um subcaminho; use `c.Base()` nos links | | `TRILHA_EXPORT` | pasta de saída: em vez de servir, exporta o site estático e sai | | `TRILHA_DEV_RELOAD` | `off` desliga a injeção do script de recarga em dev (testes de snapshot, comparação de HTML); stack traces e `no-cache` continuam | Outras configurações (limite de corpo, logger, CSRF em APIs) ficam em `trilha.Config`, que o arquivo gerado monta com `trilha.ConfigFromEnv()`. ## Inicialização com `setup.go` Abrir um banco, carregar um cache, validar variáveis: tudo isso vai em `app/setup.go`: ```go package app import "github.com/emersonjoe/trilha" func Setup(a *trilha.App) error { db, err := sql.Open("pgx", os.Getenv("DATABASE_URL")) if err != nil { return err // aborta a subida com a mensagem no terminal } trilha.Provide(a, db) return nil } ``` A página lê de volta pelo mesmo tipo: `db := trilha.Use[*sql.DB](c)`. Não guarde o pool em variável de pacote — funciona até existir um segundo app no mesmo processo (um hospedeiro que monta dois, um teste que constrói outro), e aí os dois passam a dividir o mesmo. `Values()` continua ali para cola por nome. Veja [Dependências](/trilha/pt/referencia/app#dependencias). ## `trilha export` Se todas as páginas são estáticas (um blog, uma documentação), exporte HTML e publique em qualquer hospedagem: ```bash trilha export -o out --base /agenda ``` Páginas com parâmetro entram quando `Setup` as declara com `a.AddExportPath("/eventos/x")`. Páginas que respondem um redirecionamento para o próprio site viram um pequeno HTML apontando para o destino. O site que você está lendo foi gerado assim. Um caminho exportado cujo último segmento tem ponto é gravado como esse arquivo, e não como pasta com um `index.html` dentro. É assim que uma rota produz `out/llms.txt` ou `out/feed.xml`: ```go func Setup(a *trilha.App) error { a.AddExportPath("/llms.txt", "/feed.xml") return nil } ``` É a mesma regra que a varredura usa para pasta com ponto no nome (`app/llms.txt/route.go` responde `/llms.txt`), então a rota e o arquivo exportado combinam sem inventar uma segunda convenção. ## Assets e cache Publicar HTML novo com CSS velho é o bug que ninguém consegue reproduzir dez minutos depois. A causa é sempre a mesma: o endereço do arquivo não mudou quando o conteúdo mudou, e alguma camada de cache — o navegador, um CDN, o GitHub Pages — ainda tem a versão antiga. O `Asset` põe o hash do conteúdo na URL: ```go h.Link(h.Rel("stylesheet"), h.Href(c.Asset("/style.css"))) // /style.css?v=8f3a1c92 ``` Com isso, um cache longo passa a ser seguro: ```go cfg.StaticCacheControl = "public, max-age=31536000, immutable" ``` Quem pede a URL versionada certa recebe o cache de um ano; quem pede `/style.css` sem versão cai na regra normal. Em `dev` nada é imutável e o hash acompanha o arquivo, então salvar o CSS e atualizar a página basta. O `trilha export` não precisa de nenhuma opção: o HTML exportado sai com as mesmas URLs, porque é o mesmo layout que o gera. `trilha audit` avisa quando encontra `immutable` num projeto que não usa `Asset` — é a combinação que congela um arquivo por um ano no endereço errado. ## Segurança por padrão Cabeçalhos `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` e `Referrer-Policy` em toda resposta; corpo limitado; CSRF em formulários; estáticos sem *path traversal*; logs com método, caminho, status e duração, nunca com corpo ou cookies. Erros em produção mostram uma página opaca e vão para o log com o `request_id` que aparece no cabeçalho `X-Request-ID`. ## Desafio Publique a agenda em um servidor com `systemd` e faça o serviço reiniciar sozinho se cair. :::solucao ```text [Unit] Description=agenda After=network.target [Service] ExecStart=/opt/agenda/bin/agenda Environment=PORT=8080 TRILHA_ENV=prod Restart=always User=agenda [Install] WantedBy=multi-user.target ``` ::: --- # Problemas comuns Source: /trilha/pt/aprender/problemas-comuns Erros que aparecem nos primeiros minutos e o que cada um significa. ## `zsh: command not found: trilha` O `go install` colocou o binário em `~/go/bin` (ou no que `go env GOPATH` mostrar mais `/bin`), e essa pasta não está no seu `PATH`. Adicione ao `~/.zshrc` ou `~/.bashrc` e abra um terminal novo: ```bash export PATH="$HOME/go/bin:$PATH" ``` ## `verifying module ... 404 Not Found` no `go install` O módulo está em um repositório privado, ou acabou de ficar público e o proxy ainda não o conhece. O banco de checksums `sum.golang.org` só consegue verificar módulos públicos. Para um módulo privado, diga ao Go para não verificar: ```bash go env -w GOPRIVATE=github.com/sua-org/* ``` Para um módulo recém-publicado, prefira instalar por tag (`@v0.1.0`) em vez de `@latest`. ## `app/ directory not found` Os comandos da CLI rodam na raiz do projeto, a pasta que contém `app/`. Se o app fica dentro de um módulo maior (como `examples/blog` no repositório do Trilha), rode a CLI dentro dessa subpasta: o caminho de import é calculado a partir do `go.mod` mais próximo. ## `E_NO_PAGE_FUNC` ou `E_NO_METHOD` O arquivo existe, mas a função esperada não está exportada com o nome certo. `page.go` precisa de `Page`; `route.go` precisa de pelo menos um de `GET`, `POST`, `PUT`, `PATCH`, `DELETE`; `layout.go` de `Layout`; `middleware.go` de `Middleware`. Assinatura errada é erro de compilação no `trilha_gen.go`, apontando o pacote. ## `E_UNUSED_METHOD_MIDDLEWARE` Um `MiddlewarePOST` (ou `GET`, `PUT`, `PATCH`, `DELETE`) num `middleware.go` que não alcança nenhuma rota com aquele método na sua pasta ou abaixo dela. Em geral o método mudou de lugar e a regra ficou, ou o nome tem um erro de digitação. Apague ou dê à rota o método que ela deveria guardar — uma permissão que não guarda nada é pior que permissão nenhuma, porque parece proteção. ## `E_DUPLICATE_ROUTE` Duas pastas geram a mesma URL, quase sempre por causa de um grupo de rota. `app/eventos/` e `app/organizador-/eventos/` respondem os dois em `/eventos`. Renomeie uma. ## `E_HIDDEN_ROUTE` Um `page.go` ou um `route.go` dentro de pasta cujo nome começa com ponto. O scanner pula essas pastas, então a rota nunca responderia — antes ela sumia sem uma palavra, e o único sintoma era um 404. Renomeie a pasta sem o ponto na frente ou, se ela deve mesmo ficar fora do roteamento, comece o nome com `_`. A única pasta com ponto que **é** roteada é a `.well-known` (veja [convenções](/trilha/pt/referencia/convencoes#pastas)). ## `E_UNROUTABLE_METHOD` `func HEAD`, `func TRACE` ou `func CONNECT` num `route.go`. O roteador não tira esses de um arquivo, então a função compilava e não respondia nada: a requisição caía no 405 que o fallback escreve antes de qualquer middleware. HEAD não está faltando — desde o Go 1.22 o roteador responde com o handler do `GET`, então escreva a resposta lá. Já o `OPTIONS` é um handler como os outros, e a rota que só precisa do preflight pode declarar `var CORS` em vez de escrevê-lo (veja [convenções](/trilha/pt/referencia/convencoes#origem-cruzada-numa-rota-so)). ## O preflight responde 405 A rota não serve `OPTIONS`. Ou declare `var CORS = trilha.CORS{...}` no `route.go` dela — aí o framework responde o preflight a partir da política — ou escreva `func OPTIONS` à mão. O `Config.CORS` também responde, mas para o app inteiro: use quando todas as rotas dividem a política, não para abrir três caminhos. ## Formulário responde 403 Faltou `trilha.CSRFInput(c)` dentro do `<form>`, ou a página do formulário foi aberta antes de o cookie existir (por exemplo, um `curl` direto no `POST`). Abra a página com `GET` primeiro, como um navegador faria, ou mande o token em `X-CSRF-Token`. ## O `trilha dev` diz que não há binário aqui A pasta declara um pacote diferente de `main`, então o `trilha gen` escreveu um pacote importável, com `NewApp()` e sem `func main()` — um app feito para ser montado por um binário hospedeiro (`mux.Handle("/", crm.NewApp().Handler())`). Rode o hospedeiro, não esta pasta. Se o pacote foi engano, corrija no arquivo escrito à mão e gere de novo; o arquivo gerado segue o que a pasta declara. Veja [CLI](/trilha/pt/referencia/cli#um-app-dentro-de-um-binario-que-ja-existe). ## A porta 3000 está ocupada ```bash trilha dev --addr :3001 ``` ## O navegador não recarrega O script de recarga só é injetado quando a resposta é HTML e passa pelo layout. Uma página que devolve `c.Text(...)` ou `c.JSON(...)` não recebe o script. Verifique também se algum proxy (nginx, extensão) está bloqueando `/_trilha/events`, que é uma conexão SSE. ## Mudei `public/` e nada aconteceu em produção Em produção `public/` está embutido no binário. Rode `trilha build` de novo. Em desenvolvimento a pasta é lida do disco e a mudança aparece na hora. ## A CLI fala inglês (ou português) e eu quero o outro idioma A CLI segue `TRILHA_LANG`, depois `LC_ALL`, `LC_MESSAGES` e `LANG`. Defina `TRILHA_LANG=pt` ou `TRILHA_LANG=en` para forçar um idioma; qualquer valor que não comece com `pt` significa inglês. --- # Visão geral Source: /trilha/pt/referencia Os pacotes do Trilha e o que cada um faz. | Pacote | Import | Papel | |---|---|---| | `trilha` | `github.com/emersonjoe/trilha` | runtime: `App`, `Ctx`, erros, CSRF, estáticos, export | | `h` | `github.com/emersonjoe/trilha/h` | DSL de HTML | | `tmpl` | `github.com/emersonjoe/trilha/tmpl` | adaptador para `html/template` | | `cache` | `github.com/emersonjoe/trilha/cache` | cache com prazo, tags e memo por requisição | | `ui` | `github.com/emersonjoe/trilha/ui` | kit de componentes (tema compatível com shadcn/ui) | | `ai` | `github.com/emersonjoe/trilha/ai` | cliente OpenAI-compatível, ferramentas, agentes | | `ai/mcp` | `github.com/emersonjoe/trilha/ai/mcp` | cliente e servidor MCP | | CLI | `github.com/emersonjoe/trilha/cmd/trilha` | `new`, `gen`, `dev`, `build`, `routes`, `export`, `audit`, `ui` | Nenhum deles depende de nada fora da biblioteca padrão. Compatível com Go 1.22 ou mais novo. ## Modelo mental em uma página - **Convenções de arquivo** em `app/` definem rotas, layouts e middlewares ([Convenções](/trilha/pt/referencia/convencoes)). - Toda função de rota recebe `*trilha.Ctx` ([Ctx](/trilha/pt/referencia/ctx)) e devolve `error` ou `(h.Node, error)`. - Erros são valores com significado HTTP ([Erros](/trilha/pt/referencia/erros)). - Entrada de formulário e de JSON é preenchida e conferida pelo `Bind` ([Validação](/trilha/pt/referencia/validacao)). - O HTML é um `h.Node` ([h](/trilha/pt/referencia/h)), vindo do DSL ou de um template ([tmpl](/trilha/pt/referencia/tmpl)). - `trilha_gen.go` liga tudo e é gerado pela [CLI](/trilha/pt/referencia/cli); `App` ([App](/trilha/pt/referencia/app)) é o que ele monta. ## Estabilidade Versão 0.x: a API pode mudar entre versões menores. Mudanças incompatíveis são listadas no `CHANGELOG.md` do repositório com instruções de migração. O que é "a API" está escrito. Os símbolos exportados dos pacotes da tabela acima estão cobertos pela promessa; `internal/`, a saída exata da CLI e o HTML que os componentes do `ui` produzem, não. Antes de um símbolo coberto sumir, ele ganha uma nota `Deprecated:` dizendo o substituto, uma linha no CHANGELOG e pelo menos uma versão menor convivendo com ele. A superfície inteira é versionada em [`api/current.txt`](https://github.com/emersonjoe/trilha/blob/main/api/current.txt), uma linha por símbolo, e um teste falha quando ela muda — assim a remoção aparece na revisão em vez de aparecer na sua compilação. As regras estão no [`API.md`](https://github.com/emersonjoe/trilha/blob/main/docs/pt-BR/API.md). --- # Convenções de arquivo Source: /trilha/pt/referencia/convencoes Tabela completa do que cada arquivo e nome de pasta em app/ significa. ## Arquivos | Arquivo | Função exportada | Assinatura | Alcance | |---|---|---|---| | `page.go` | `Page` | `func(c *trilha.Ctx) (h.Node, error)` | rota GET da pasta | | `page.go` | `POST`, `PUT`, `PATCH`, `DELETE` (opcionais) | `func(c *trilha.Ctx) error` | formulários; CSRF exigido | | `route.go` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS` (ao menos um) | `func(c *trilha.Ctx) error` | API JSON da pasta | | `kind.go`, ou qualquer arquivo (opcional) | `Kind` | `var Kind = trilha.KindPage` ou `KindAPI` | subárvore: como erros são renderizados e se há CSRF (veja [Erros](/trilha/pt/referencia/erros)) | | `route.go` (opcional) | `CORS` | `var CORS = trilha.CORS{...}` | política de origem cruzada só desta rota, preflight incluído | | `layout.go` | `Layout` | `func(c *trilha.Ctx, children h.Node) (h.Node, error)` | subárvore | | `middleware.go` | `Middleware` | `func(c *trilha.Ctx, next trilha.Next) error` | subárvore | | `middleware.go` (opcional) | `MiddlewareGET`, `MiddlewarePOST`, `MiddlewarePUT`, `MiddlewarePATCH`, `MiddlewareDELETE`, `MiddlewareOPTIONS` | `func(c *trilha.Ctx, next trilha.Next) error` | subárvore, só naquele método | | `not_found.go` (só na raiz) | `NotFound` | `func(c *trilha.Ctx) (h.Node, error)` | 404 do app | | `error.go` (só na raiz) | `Error` | `func(c *trilha.Ctx, err error) (h.Node, error)` | todo status de erro menos o 404 | | `setup.go` (só na raiz) | `Setup` | `func(a *trilha.App) error` | antes de servir | | `setup.go` (opcional) | `Config` | `func(cfg *trilha.Config)` ou `func(cfg *trilha.Config) error` | antes de `trilha.New`; o erro interrompe a subida | | `setup.go` (opcional) | `Shutdown` | `func(a *trilha.App) error` | depois de parar de aceitar requisições (fechar pool, fila, flush de log) | `page.go` e `route.go` na mesma pasta é erro. A função pode estar em qualquer arquivo do pacote; o nome do arquivo é o que liga a convenção. ### O Kind segue a subárvore `Kind` é variável, não função, e é herdado como o `Layout` e o `Middleware`: declarado no pacote de uma pasta, vale para ela e para tudo abaixo, e a declaração mais funda ganha. `kind.go` é o nome do arquivo para a pasta que não tem `route.go` próprio — a raiz de uma subárvore precisa poder falar sem ter rota: ```go // app/painel/kind.go — este ramo é de páginas do navegador, então as escritas cobram CSRF package painel var Kind = trilha.KindPage ``` Isso pesa mais do que a renderização do erro: **`Kind` é o que liga o CSRF**. Um `route.go` nasce API, e API não confere o token, então a mesma ação de formulário movida de um `page.go` para um `route.go` passa a aceitar POST de outro site — em silêncio. Uma linha na raiz do ramo cobre todas as folhas, inclusive a que alguém criar no mês que vem. O `trilha audit` aponta a rota de escrita que nenhum `Kind` alcança num app que também serve páginas. Uma rota de `page.go` é página independente do que o ramo acima diz: um `KindAPI` herdado nunca transforma página em JSON. ## Pastas | Nome | Vira | Exemplo | |---|---|---| | `eventos` | segmento literal | `/eventos` | | `slug_` | parâmetro `{slug}` | `/eventos/{slug}` → `c.Param("slug")` | | `caminho__` | catch-all `{caminho...}`; precisa ser folha | `/docs/{caminho...}` | | `organizador-` | grupo de rota; não entra na URL | layout/middleware para a subárvore | | `app.css`, `robots.txt` | caminho fixo com extensão (ponto no meio do nome) | `/app.css`, `/manifest.webmanifest`, `/sw.js` | | `.well-known` | a única pasta com ponto no começo que é rota | `/.well-known/security.txt` | | `_x`, `.x`, `testdata` | ignoradas | — | Uma pasta com ponto no nome serve um caminho fixo com extensão. Como `app.css` não é um identificador Go, declare outro nome de pacote no arquivo (`package appcss`); o gerador importa tudo com alias, então o nome do pacote não importa. Pastas que **começam** com ponto continuam ignoradas, com uma exceção: `.well-known`, onde a RFC 8414, a RFC 9728, a RFC 8555, a RFC 9116 e o OpenID Discovery mandam publicar documento. Dentro dela valem as convenções de sempre — `app/.well-known/security.txt/route.go` responde `/.well-known/security.txt`. Um `page.go` ou `route.go` dentro de **qualquer outra** pasta com ponto agora é erro `E_HIDDEN_ROUTE`, em vez de um 404 que ninguém explica; para tirar uma pasta do roteamento de propósito, comece o nome com `_`. A ferramenta Go não casa caminho com ponto no padrão `./...`, então `go vet ./...` e `go test ./...` não pegam esse pacote como alvo. Ele compila do mesmo jeito: o `trilha_gen.go` o importa pelo caminho explícito. ## Origem cruzada numa rota só `Config.CORS` é a política do app inteiro. Quando só alguns caminhos são públicos — os documentos de descoberta em `/.well-known/`, buscados de outra origem por um cliente que ainda não tem sessão —, a política mora na rota: ```go package oauthresource // Só esta rota. As outras seguem de mesma origem. var CORS = trilha.CORS{Origins: []string{"*"}, Methods: []string{"GET"}} func GET(c *trilha.Ctx) error { ... } ``` O framework responde o preflight a partir dela (204 com `Access-Control-Allow-*`, ou 403 se a origem ou o método estiverem fora da lista) e põe os cabeçalhos em toda resposta daquela rota. A rota que declara a própria política decide sozinha: a lista do app não a alarga nem a estreita. Escrever `func OPTIONS` no mesmo arquivo retoma o preflight — o caso comum é declarativo, o esquisito continua seu. `HEAD` não é nome de handler: desde o Go 1.22 o roteador responde HEAD com o handler do `GET`. Precedência: literal vence parâmetro, que vence catch-all. Duas pastas dinâmicas irmãs são erro. Duas pastas que gerem a mesma URL (via grupos) são erro. ## Outras pastas do projeto | Pasta | Papel | |---|---| | `public/` | arquivos estáticos servidos na raiz; embutidos no binário em produção | | `trilha_gen.go` | gerado; commitado; nunca editado à mão; leva o pacote que a pasta declara (veja [CLI](/trilha/pt/referencia/cli)) | | `.trilha/` | binários temporários do `dev` e do `export`; ignorada pelo git | ## Ordem de execução de `GET /a/b` ```text middleware(app) → middleware(app/a) → middleware(app/a/b) → middlewareGET(app) → middlewareGET(app/a) → middlewareGET(app/a/b) → Page (ou método) → layout(app/a/b) → layout(app/a) → layout(app) ``` A cadeia do método roda por dentro da cadeia da rota: uma regra de um método só refina o que a rota já decidiu. Para `POST` é `MiddlewarePOST`, e assim por diante; um método sem cadeia própria roda só a da rota. ## Erros de geração | Código | Causa | |---|---| | `E_PAGE_AND_ROUTE` | `page.go` e `route.go` na mesma pasta | | `E_NO_PAGE_FUNC` | `page.go` sem `Page` | | `E_NO_METHOD` | `route.go` sem método exportado | | `E_NO_LAYOUT_FUNC`, `E_NO_MIDDLEWARE_FUNC`, `E_NO_NOT_FOUND_FUNC`, `E_NO_ERROR_FUNC`, `E_NO_SETUP_FUNC` | arquivo sem a função esperada | | `E_AMBIGUOUS_SEGMENT` | duas pastas dinâmicas no mesmo nível | | `E_CATCHALL_NOT_LEAF` | rotas abaixo de uma pasta `x__` | | `E_BAD_SEGMENT` | nome de parâmetro inválido ou grupo dinâmico (`x_-`) | | `E_DUPLICATE_ROUTE` | duas pastas produzindo a mesma URL | | `E_UNUSED_METHOD_MIDDLEWARE` | `MiddlewareX` que não alcança nenhuma rota que sirva `X` | | `E_PARSE` | arquivo Go que não compila | | `E_NO_APP` | não há pasta `app/` | | `E_HIDDEN_ROUTE` | `page.go` ou `route.go` dentro de pasta cujo nome começa com ponto | | `E_UNROUTABLE_METHOD` | `func HEAD`, `TRACE` ou `CONNECT`: o roteador não tira esses de um arquivo | | `E_CORS_ON_PAGE` | `var CORS` num `page.go` | --- # Ctx Source: /trilha/pt/referencia/ctx Tudo que uma função de rota pode fazer com o contexto da requisição. `*trilha.Ctx` embrulha a requisição e a resposta. É criado por requisição e não deve ser usado por outra goroutine depois que o handler devolve. ## Requisição | Método | Descrição | |---|---| | `Request() *http.Request` | a requisição original | | `SetContext(ctx)` | troca o contexto da requisição: um middleware passa valores a código que só recebe `*http.Request` | | `SetRequest(*http.Request)` | troca a requisição (URL reescrita, corpo embrulhado) | | `Context() context.Context` | contexto da requisição (cancelamento) | | `Param(nome) string` | parâmetro de rota (`slug_` → `"slug"`) | | `Pattern() string` | o gabarito da rota que casou (`/blog/{slug}`), a forma agregável do caminho; `""` para o que o fallback respondeu (estático, 404, redirecionamento de barra) | | `Query(nome) string` | primeiro valor do parâmetro de query | | `Form(nome) string` | campo do formulário (faz o parse sob demanda, com limite de tamanho) | | `FormErr() error` | erro do parse do formulário: 400 inválido, 413 grande demais | | `BindJSON(&v) error` | decodifica o corpo JSON; campos desconhecidos são erro (400); 413 acima do limite | | `Cookie(nome) (*http.Cookie, error)` | cookie da requisição | | `Accepts(ofertas...) string` | a oferta que o cliente prefere (`Accept`, ranqueado por `q`), ou `""`; cabeçalho ausente ou `*/*` fica com a primeira oferta | | `RequestID() string` | `X-Request-ID` recebido ou um id gerado | | `Env() trilha.Env` | `trilha.Dev` ou `trilha.Prod` | | `Base() string` | prefixo de URL (`TRILHA_BASE_PATH`), sem barra final | | `App() *trilha.App` | a aplicação | | `Fragment() string` | id que o cliente quer trocar (cabeçalho `Trilha-Fragment`), ou `""` numa navegação normal ([Interatividade](/trilha/pt/aprender/interatividade)) | ## Resposta | Método | Descrição | |---|---| | `JSON(code, v) error` | escreve JSON com `Content-Type` correto | | `Text(code, s) error` | escreve texto simples | | `HTML(code, node) error` | escreve um nó como documento inteiro, sem layouts | | `Redirect(url) error` | devolve o erro de redirecionamento 303 (use com `return`) | | `Status(code)` | status que a próxima renderização de página vai usar | | `Header(k, v)` | define um cabeçalho de resposta | | `SetCookie(*http.Cookie)` | adiciona `Set-Cookie` | | `Flash(tipo, texto)` | guarda um aviso para a requisição seguinte, num cookie assinado: a notícia que o redirect comeria. O `ui.Flashes(c)` mostra. Numa resposta de fragmento ele vai no cabeçalho `Trilha-Flash`, e quem mostra é o `ui.js`. Sem `TRILHA_SECRET` nada é escrito e o app avisa uma vez no log | | `Flashes() []Flash` | os avisos deixados pela requisição anterior mais os que esta ainda não mandou; ler é gastar, e ler duas vezes dá a mesma lista | | `Render(code, node) error` | escreve a página **com os layouts da rota** (como o GET): para um `POST` devolver o formulário com erros (422); num fragmento, sem os layouts | | `Stream() *Stream` | resposta em Server-Sent Events: `Send(evento, dados)`, `JSON(evento, v)`, `Comment(s)`, `Done()`; desliga o *write timeout* ([IA e agentes](/trilha/pt/aprender/ia-e-agentes)) | | `Writer() http.ResponseWriter` | acesso direto (downloads longos, WebSocket) | | `Written() bool` | se a resposta já começou | ## Cache HTTP | Método | Descrição | |---|---| | `ETag(tag) bool` | escreve `ETag` (com aspas, se faltarem) e diz se a requisição já a tinha | | `LastModified(t) bool` | escreve `Last-Modified` e diz se a cópia está em dia | | `CacheControl(v)` | escreve `Cache-Control` como veio | `true` quer dizer que o `304` já foi escrito: devolva `nil, nil` e não escreva mais nada. Só `GET` e `HEAD` respondem `304`; nos outros métodos os cabeçalhos são escritos e a resposta é sempre `false`. Etiqueta vazia ou data zerada não escrevem nada. Quando os dois são declarados, quem decide é o `If-None-Match` e a data fica como metadado, como pede a RFC 9110. Os arquivos em `static/` já vêm com ETag: a impressão digital do conteúdo que vai no `?v=`. ## Entre página e layout | Método | Descrição | |---|---| | `SetTitle(s)` / `Title() string` | título da página, lido pelos layouts | | `Set(chave, v)` / `Get(chave) any` | valores por requisição (middleware → página → layout) | ## Ilhas ```go func (c *Ctx) Island(src string, props any, children ...h.Node) h.Node ``` Renderiza `<div data-trilha-island="…" data-trilha-props="…">` com os filhos como conteúdo de origem, vindo do servidor. `src` é um módulo em `public/` (endereçado pelo `Asset`, então leva o hash do conteúdo) cuja **exportação padrão** é a função de montagem, chamada uma vez com `(el, props)`. `props` é qualquer coisa que o `encoding/json` serialize, ou `nil`; viaja como atributo escapado e volta pelo `JSON.parse`, então é dado, nunca marcação. Props que não serializam avisam uma vez e deixam o conteúdo de origem em paz. O carregador é um único script inline com o nonce da requisição, emitido junto da primeira ilha da resposta ([Interatividade](/trilha/pt/aprender/interatividade)). ## Conexão longa e corpo grande | Método | Descrição | |---|---| | `AllowBody(n int64)` | limite de corpo **desta** requisição, no lugar do `Config.MaxBodyBytes` | | `NoReadDeadline() error` | tira o prazo de leitura desta requisição (upload lento não é erro) | | `NoWriteDeadline() error` | tira o prazo de escrita (download longo, SSE) | | `Hijack() (net.Conn, *bufio.ReadWriter, error)` | assume a conexão: prazos removidos, e o Trilha não escreve mais nada nela | O limite padrão é do app; a exceção é da rota. Levante no `middleware.go` da rota, não no handler — o CSRF de formulário lê o corpo antes do handler rodar, então a decisão tem de vir antes: ```go // app/anexos/middleware.go func Middleware(c *trilha.Ctx, next trilha.Next) error { if c.Request().Method == "POST" { c.AllowBody(8 << 20) // só esta requisição; o resto do app segue no limite do app c.NoReadDeadline() } return next() } ``` Estourar o limite continua sendo 413 com a mensagem de sempre, pelo `FormErr`, pelos `Bind*` ou na leitura direta do `Request().Body`. ### WebSocket O Trilha não tem WebSocket próprio, e isso é decisão. O protocolo é transporte: não encosta em rota, em layout nem em render. O que ele exige — frames de fragmentação e continuação, frame de controle no meio de uma mensagem, aperto de mão de fechamento com prazo, validação de UTF-8, máscara, limite de tamanho, escrita concorrente, contrapressão, `permessage-deflate` — são algumas centenas de linhas que a suíte Autobahn cobra em mais de 500 casos. A assimetria decide: o seu app pode pôr `coder/websocket` no go.mod **dele** (o princípio II obriga o framework, não o app), mas não consegue tirar essas linhas do framework. O que faltava era a porta, e ela é o `Hijack`: ```go func WS(c *trilha.Ctx) error { conn, _, err := c.Hijack() // prazos de leitura e escrita já removidos if err != nil { return err } defer conn.Close() return meuWebsocket.Serve(conn) // coder/websocket, gorilla, o que você escolher } ``` Depois do `Hijack` a conexão é sua: o framework não escreve cabeçalho, página de erro nem corpo nela, e o log de acesso registra 101. ## Segurança | Método | Descrição | |---|---| | `CSRFToken() string` | token da requisição; cria o cookie na primeira chamada | | `trilha.CSRFInput(c) h.Node` | `<input type="hidden" name="_csrf">` para formulários | | `trilha.CSRFTokenFrom(r) string` | o mesmo token, para quem só recebe o `*http.Request` (`html/template`, `templ`, um handler seu); `""` fora de uma requisição da Trilha | | `trilha.NonceFrom(r) string` | o nonce da CSP daquela requisição, mesmo motivo e mesma regra ([Segurança](/trilha/pt/referencia/seguranca)) | O token é verificado automaticamente em `POST`, `PUT`, `PATCH` e `DELETE` de `page.go` (e de `route.go` se `Config.CSRFForAPI` estiver ligado), pelo campo `_csrf` ou pelo cabeçalho `X-CSRF-Token`. ## Bind `Bind(v any) error` preenche uma struct a partir do formulário (ou do JSON, quando o `Content-Type` é `application/json`). Campos casam pela tag `form:"nome"` (ou pelo nome do campo); tipos: `string`, `[]string`, `bool` (`on`/`true`/`1`), `int`, `int64`, `float64` (vírgula ou ponto), `time.Time` (`2006-01-02` ou `2006-01-02T15:04`) e ponteiros (nil quando ausente). Struct aninhada é achatada, com a tag como prefixo (`Cobranca Endereco `+"`form:\"cob_\"`"+` lê `cob_cep`…). Valores que não convertem viram `FieldErrors` (mensagem `trilha.BindInvalid`, ajustável) depois de todos os campos serem tentados. A tag `validate:"..."` de cada campo é aplicada logo em seguida, na mesma passada: veja [Validação](/trilha/pt/referencia/validacao). ## File `File(campo string, regras FileRules) (*Upload, error)` lê um arquivo do formulário multipart e só o devolve se ele passar pelas regras. | Símbolo | Papel | |---|---| | `FileRules.MaxSize int64` | limite deste arquivo, à parte do `Config.MaxBodyBytes`; 0 deixa o limite do corpo trabalhar | | `FileRules.Accept []string` | tipos aceitos, comparados com o tipo **detectado**: `"image/png"`, `"image/*"`, `"*/*"`; vazio aceita qualquer um | | `FileRules.Optional bool` | campo ausente devolve `(nil, nil)` em vez de erro | | `Upload.Name` | nome sanitizado: sem diretório, sem separador, sem caractere de controle, no máximo 100 caracteres, nunca vazio | | `Upload.MIME` / `Upload.Ext` | tipo detectado nos primeiros 512 bytes, e a extensão correspondente | | `Upload.Size` / `Upload.File` | tamanho em bytes, e o arquivo posicionado no começo | | `up.Save(dir) (string, error)` | grava dentro de `dir` (modo 0600) com um nome livre e devolve o caminho | | `up.Close() error` | fecha o arquivo | Regra que falha vira `FieldErrors` no nome do campo, como no `Bind`; qualquer outra coisa (corpo quebrado, disco cheio) volta como está. As mensagens saem do `ValidationMessages` (`required`, `filemax`, `filetype`) — veja [Validação](/trilha/pt/referencia/validacao). --- # Pacote h Source: /trilha/pt/referencia/h Referência do DSL de HTML: nós, elementos, atributos e fluxo de controle. ```go import "github.com/emersonjoe/trilha/h" ``` ## O tipo Node ```go type Node interface { Render(w io.Writer) error } ``` Qualquer valor com esse método pode ser filho de um elemento. `h.Render(n) (string, error)` é a conveniência para testes. ## Texto e estrutura | Função | Saída | |---|---| | `Text(s)` | texto escapado | | `Textf(fmt, a...)` | texto formatado e escapado | | `Raw(html)` | HTML sem escape — a única porta | | `Fragment(filhos...)`, `Group(...)` | filhos em sequência, sem elemento em volta | | `Doctype()` | `<!doctype html>` | | `Nil` | nó vazio | | `El(tag, filhos...)` | elemento com tag arbitrária | | `Void(tag, attrs...)` | elemento vazio arbitrário | ## Fluxo de controle | Função | Comportamento | |---|---| | `If(cond, n)` | `n` se verdadeiro, vazio se falso | | `IfElse(cond, a, b)` | `a` ou `b` | | `Map(itens, f)` | `f(item)` para cada item | | `MapIndex(itens, f)` | `f(i, item)` | `nil` como filho é ignorado. ## Elementos Todos os elementos HTML de uso comum têm uma função com o nome em maiúscula inicial: `Html`, `Head`, `Body`, `Title`, `Meta`, `Link`, `Script`, `Style`, `Div`, `Span`, `P`, `A`, `Ul`, `Ol`, `Li`, `H1`…`H6`, `Header`, `Footer`, `Main`, `Nav`, `Section`, `Article`, `Aside`, `Form`, `Input`, `Button`, `Label`, `Select`, `Option`, `Textarea`, `Table`, `Thead`, `Tbody`, `Tr`, `Th`, `Td`, `Img`, `Br`, `Hr`, `Pre`, `Code`, `Strong`, `Em`, `Small`, `Time`, `Details`, `Summary`, `Dialog`, `Figure`, `Picture`, `Video`, `Audio`, `Canvas`, `Iframe`, `Svg`, `Template`, entre outros. Elementos vazios (`Br`, `Img`, `Input`, `Meta`, `Link`, `Hr`, `Source`, `Track`, `Wbr`, `Area`, `Col`, `Embed`, `Base`) aceitam só atributos. ## Atributos | Função | Atributo | |---|---| | `Attr(nome, valor)` | qualquer atributo, valor escapado | | `Bool(nome)` | atributo booleano | | `Class(v...)` | `class`, juntando com espaço e ignorando vazios | | `ID`, `Href`, `Src`, `Alt`, `Type`, `Name`, `Value`, `Placeholder`, `Action`, `Method`, `Rel`, `Lang`, `Charset`, `Content`, `For`, `Role`, `Target`, `Width`, `Height`, `Rows`, `Cols`, `Min`, `Max`, `Step`, `Pattern`, `Maxlength`, `Minlength`, `Autocomplete`, `Inputmode`, `Enctype`, `Accept`, `Datetime`, `Tabindex`, `Onclick` | o atributo de mesmo nome | | `StyleAttr`, `TitleAttr`, `LabelAttr` | `style`, `title`, `label` (os nomes sem sufixo são elementos) | | `Data(chave, v)`, `Aria(chave, v)` | `data-chave`, `aria-chave` | | `Attrs(...)` | vários atributos num nó só, para um componente que põe mais de um no elemento onde é colocado (`ui.Confirm`); o que não é atributo é descartado | | `Disabled()`, `Checked()`, `Selected()`, `Required()`, `Autofocus()`, `Hidden()`, `Readonly()`, `Multiple()`, `Open()`, `Defer()`, `Async()`, `Autoplay()`, `Controls()`, `Novalidate()` | booleanos | Atributos podem aparecer em qualquer posição entre os filhos; são escritos na tag de abertura, na ordem em que aparecem. --- # Pacote tmpl Source: /trilha/pt/referencia/tmpl Usar html/template dentro do pipeline de páginas e layouts. ```go import "github.com/emersonjoe/trilha/tmpl" ``` | Função | Descrição | |---|---| | `Node(t *template.Template, nome string, dados any) h.Node` | nó que executa o template nomeado; erro de execução vira erro de render (500), sem saída parcial | | `Must(fsys fs.FS, padrões ...string) *template.Template` | `template.ParseFS` com pânico em erro; chame em nível de pacote para falhar na subida | | `Wrap(t *template.Template, nome, slot string) *Shell` | prepara uma casca para receber um `h.Node` onde ela chama `{{template slot .}}`; chame em nível de pacote — ele clona o conjunto, e o `html/template` só clona um conjunto que ainda não executou | | `(*Shell) Node(dados any, filhos h.Node) h.Node` | renderiza a casca com `dados` e os `filhos` no slot; casca que nunca chega ao slot é erro de render | | `HTML(n h.Node) (template.HTML, error)` | o nó como `template.HTML`, para dados de um template que o próprio app executa | ## Uso ```go //go:embed *.html var arquivos embed.FS var t = tmpl.Must(arquivos, "*.html") func Page(c *trilha.Ctx) (h.Node, error) { c.SetTitle("Relatório") return tmpl.Node(t, "relatorio", dados), nil } ``` O template usa `{{define "relatorio"}}...{{end}}` e recebe `dados` como `.`. O escape é o contextual do `html/template`. O nó pode ser combinado com o DSL: `h.Section(h.Class("x"), tmpl.Node(t, "parte", d))`. ## O caminho inverso: um h.Node dentro de um template A casca de um app que já existe em `html/template`, com as telas novas escritas em `h`, é o meio do caminho de toda migração. O `Wrap` liga os dois: ```go var casca = tmpl.Wrap(tmpl.Must(arquivos, "*.html"), "casca", "conteudo") func Layout(c *trilha.Ctx, children h.Node) (h.Node, error) { return casca.Node(pagina(c.Request()), children), nil } ``` O template continua com a forma que tinha — o slot é o `{{template "conteudo" .}}` que já estava lá — e nada no app converte coisa nenhuma para `template.HTML`: o que o `h` renderizou foi escapado na entrada, e o `tmpl` é o único lugar que afirma isso. Os dados da casca podem sair só do `*http.Request`, inclusive o [`trilha.NonceFrom(r)` e o `trilha.CSRFTokenFrom(r)`](/trilha/pt/referencia/ctx). O `examples/blog` tem a coisa inteira em `app/legado-`. O `Wrap` clona o conjunto, então chame em nível de pacote: o `html/template` recusa clonar um conjunto que já executou. Uma casca que nunca chega ao slot — um `{{if}}` que o escondeu, o nome errado — quebra o render com `tmpl: template %q never rendered the slot` em vez de responder calada uma página sem conteúdo. O `HTML(n)` é a saída de baixo nível, para um template que o próprio app executa. --- # Erros Source: /trilha/pt/referencia/erros Os valores de erro que o Trilha entende e como cada um vira resposta. Handlers devolvem `error`. O Trilha traduz: | Valor | Página (`page.go`) | API (`route.go`) | |---|---|---| | `nil` | resposta escrita pelo handler; 204 se nada foi escrito | idem | | `trilha.ErrNotFound` (ou erro que o embrulha) | 404 com `not_found.go` | 404 `problem+json`, `"title":"Not Found"` | | `*trilha.RedirectError` via `trilha.Redirect(url)` (303) ou `trilha.RedirectCode(url, code)` | redirecionamento | redirecionamento | | `*trilha.HTTPError` via `trilha.Errorf(code, fmt, a...)` | o status, com o `error.go` (4xx) | o status, com a mensagem em `detail` (4xx) | | qualquer outro `error` | 500 com `error.go`; detalhe só em dev | 500, com `detail` só em dev | | `*trilha.Problem` | o status, com o `error.go` | o problema, do jeito que foi escrito | | `panic` no handler | recuperado e tratado como 500; stack só em dev | idem | ### Página ou problem+json? A coluna é decidida por rota; o desempate é o cabeçalho `Accept`, ranqueado por `q`: - `page.go` → sempre página. Um fragmento trocado na página precisa de HTML mesmo quando o `fetch` diz outra coisa. - `route.go` → `problem+json`, **exceto** quando o `Accept` prefere `text/html` a `application/json` — o navegador na barra de endereço. O caminho não entra na conta: um `route.go` dentro de `/api/` mostra a página de erro para o navegador como qualquer outro. - `Accept` ausente, ou `*/*` (`fetch`, `curl`), não é preferência: quem decide é o tipo da rota. - `var Kind = trilha.KindPage` (sempre página, com CSRF exigido em `POST`/`PUT`/`PATCH`/`DELETE`) ou `trilha.KindAPI` (sempre `problem+json`, diga o `Accept` o que disser) fixa o comportamento. Ele é herdado pela subárvore inteira, então um `kind.go` na raiz de um ramo decide todo `route.go` abaixo dele; veja [Convenções de arquivo](/trilha/pt/referencia/convencoes#o-kind-segue-a-subarvore). - Sem rota nenhuma (404) não há tipo para perguntar: decide o `Accept` e, quando ele está mudo, o prefixo `/api/` é o último recurso. ### Uma página para todo status menos o 404 O `app/error.go` responde **todo** status de erro, não só os 5xx: um 403 num app com papéis é a resposta mais comum depois do 200, e merece o menu, o texto e o layout do app. O `app/not_found.go` continua com o 404 — ele existe e é o lugar. A assinatura não muda; o status vem do erro: ```go func Error(c *trilha.Ctx, err error) (h.Node, error) { switch trilha.StatusOf(err) { case http.StatusForbidden: return painel.Negado(c), nil default: return painel.Erro(c), nil } } ``` `trilha.StatusOf(err)` diz o status que o framework vai mandar — a mesma classificação da tabela acima. (`c.Status` é um setter; a página recebe o erro, não o código, e é por isso que a função existe.) A página do próprio framework continua como rede, com o texto de sempre: para o app que não tem `error.go` e para o `error.go` que falha. Rota de API (`KindAPI`) segue intocada: `problem+json` como antes. ### Responder por conta própria `not_found.go`, `error.go` e `page.go` podem escrever a resposta inteira e devolver `(nil, nil)`: o Trilha não põe nada em cima. Serve para um 404 em texto puro (`http.NotFound(c.Writer(), c.Request())`), outro `Content-Type` ou outro status. Se a função devolve `nil` **sem** escrever, vale a página simples do framework (404/500); em `page.go`, 204. Mensagens de `HTTPError` com código 5xx nunca são mostradas ao cliente. Todo erro 5xx vai para o log com o `request_id`. ```go if ev, ok := eventos.Buscar(slug); !ok { return trilha.ErrNotFound } if vagas < 0 { return trilha.Errorf(422, "vagas não pode ser negativo") } return c.Redirect("/eventos/" + ev.Slug) ``` Erros de `c.BindJSON` e `c.FormErr` já são `HTTPError` (400 ou 413): basta devolvê-los. ## Problem Erro de API é *problem details*, do [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457), enviado como `application/problem+json`: ```json {"type":"about:blank","title":"Unprocessable Entity","status":422, "instance":"/api/posts","request_id":"01J…","fields":{"title":"obrigatório"}} ``` Devolva um `*trilha.Problem` para dizer mais do que um status: ```go return &trilha.Problem{ Type: "https://exemplo.com/probs/sem-saldo", Title: "Sem saldo", Status: http.StatusPaymentRequired, Detail: "A conta tem R$ 3,00 e a operação custa R$ 10,00.", Extra: map[string]any{"saldo": 300}, } ``` | Campo | Papel | |---|---| | `Type` | URI que nomeia o tipo de problema; padrão `about:blank` | | `Title` | resumo curto, igual em toda ocorrência; padrão o texto do status | | `Status` | status HTTP | | `Detail` | o que aconteceu **desta** vez; é lido por uma pessoa | | `Instance` | esta ocorrência; padrão o caminho da requisição | | `Fields` | os `FieldErrors` de um 422 | | `Extra` | membros de extensão, escritos no objeto de cima (o `saldo` do exemplo) | `trilha.ProblemType` (um `func(status int) string`) preenche o `Type` de todo problema que não trouxer um — para o app que documenta os próprios erros em uma URL sua. Em produção, um 5xx nunca leva `Detail`, e a mensagem vai para o log com o `request_id`; em `Dev`, ela vem na resposta. O `Detail` que **você** escreveu é seu e sai sempre: a regra é sobre o que o framework vazaria, não sobre o que você decidiu contar. ## Negociação de conteúdo `c.Accepts(ofertas...)` devolve a oferta que o cliente prefere, ranqueada pelos `q` do `Accept`, ou `""` quando ele não aceita nenhuma. `Accept` ausente ou `*/*` não é preferência, então ponha o seu padrão primeiro: ```go switch c.Accepts("text/html", "application/json") { case "application/json": return c.JSON(200, ev) default: return c.Render(200, pagina(ev)) } ``` ## FieldErrors `trilha.FieldErrors` é `map[string]string` (campo → mensagem) que implementa `error`. Devolvido de um handler responde **422**: JSON com `"fields"` em rotas de API, página de erro em páginas. Um formulário normalmente não o devolve: valida, e no erro chama `c.Render(422, …)` mostrando cada mensagem no campo (`ui.Errors`, `ui.InvalidIf`). | Método | Papel | |---|---| | `Add(campo, msg)` | registra (a primeira mensagem do campo vence) | | `Has(campo) bool`, `Get(campo) string` | consulta | | `Any() bool` | há erros? | | `OrNil() error` | `nil` quando vazio, para `return errs.OrNil()` | --- # Validação Source: /trilha/pt/referencia/validacao A tag validate, as regras por tipo, as suas próprias regras e as mensagens que o Bind devolve. O `Bind` valida enquanto preenche: depois de converter os valores, aplica a tag `validate` de cada campo e devolve `FieldErrors` (campo → mensagem) com tudo o que falhou. As mesmas regras valem para formulário e para JSON — com corpo JSON o campo é nomeado pela tag `json`, que é o nome que o cliente reconhece. ```go type entrada struct { Nome string `form:"nome" validate:"required,min=3,max=80"` Email string `form:"email" validate:"required,email"` Confirma string `form:"confirma" validate:"eqfield=email"` Data time.Time `form:"data" validate:"required,min=2026-01-01"` Plano string `form:"plano" validate:"oneof=gratis pro"` Desconto *int `form:"desconto" validate:"required,min=0"` } ``` ## Regras | Regra | Texto | Número | `time.Time` | `[]string` (checkbox, select) | |---|---|---|---|---| | `required` | não vazio | qualquer valor; `0` só por ponteiro | não é a data zero | ao menos um | | `min=n` | ao menos `n` caracteres | valor `>= n` | data não é antes de `n` (`2006-01-02`) | ao menos `n` escolhidos | | `max=n` | no máximo `n` caracteres | valor `<= n` | data não é depois de `n` | no máximo `n` escolhidos | | `len=n` | exatamente `n` caracteres | — | — | exatamente `n` escolhidos | | `email` | um `@`, domínio com ponto | — | — | — | | `url` | `http`/`https` absoluta | — | — | — | | `oneof=a b c` | valor é uma das opções, separadas por espaço | igual, como texto | — | — | | `eqfield=outro` | igual ao valor do outro campo, pelo nome de formulário | igual | igual | — | As regras são separadas por vírgula e aplicadas em ordem; a primeira que falha é a mensagem daquele campo. Toda regra além de `required` ignora valor vazio, então campo opcional só responde pelo que alguém digitou. Valor que nem converte (`abc` num `int`) recebe `trilha.BindInvalid` e nenhuma mensagem de regra — uma mensagem por campo. **`required` é o valor zero**: `0`, `false`, `""` e a data zero não passam. Onde zero é resposta de verdade, declare o campo como ponteiro: um `*int` que chegou com `0` está presente, e só o campo ausente falha. ## Regras suas | Símbolo | Papel | |---|---| | `trilha.Validator` | `interface{ Validate() error }`: o valor se confere | | `trilha.AddRule(nome, func(Field) bool)` | registra um nome para a tag; nome repetido entra em pânico | | `trilha.Field` | o que a regra vê: `Name`, `Param`, `Text`, `Value`, `Other(nome)` | | `trilha.ValidationMessages` | `map[string]string` das mensagens; `{param}` é substituído | | `trilha.UseValidationPTBR()` | troca as mensagens, `BindInvalid` incluído, para português | Campo cujo **tipo** tem `Validate() error` é conferido depois de as regras da tag passarem, e a mensagem do erro vai para `FieldErrors` como está (receptor por valor ou por ponteiro, os dois funcionam). A **struct** também pode ter `Validate() error`: roda no fim, só quando nenhum campo falhou — é o que torna segura uma conferência que lê dois campos. Ela pode devolver `FieldErrors` para dizer de quem é a culpa; qualquer outro erro volta do `Bind` intacto. ```go trilha.AddRule("cep", func(f trilha.Field) bool { return cepValido(f.Text) }) trilha.ValidationMessages["cep"] = "CEP inválido" ``` `Field.Value` é o valor convertido (`string`, `bool`, `int64`, `float64`, `time.Time`, `[]string`, ou `nil` quando o campo não veio) e `Field.Text` é a mesma coisa como texto, que é tudo de que a maioria das regras precisa. Regra que compara campos lê o outro com `f.Other("email")`. ## Onde a validação para A tag diz o que um **valor** aceita. Se a conta existe, se a sala está livre nessa noite, se essa pessoa pode fazer isso — essas leem os seus dados e são do seu pacote. Rode depois do `Bind` e junte no mesmo `FieldErrors`, para todas as mensagens chegarem numa resposta só: ```go errs := trilha.FieldErrors{} if err := c.Bind(&in); err != nil { fe, ok := err.(trilha.FieldErrors) if !ok { return err } errs = fe } for campo, msg := range plano.Validar(&in) { errs.Add(campo, msg) } if errs.Any() { return c.Render(http.StatusUnprocessableEntity, pagina(c, in, errs)) } ``` Nome de regra que ninguém registrou entra em pânico na primeira requisição que passa pelo campo, de propósito: um erro de digitação na tag seria, senão, um formulário que aceita qualquer coisa em produção. --- # App e Config Source: /trilha/pt/referencia/app O que o arquivo gerado monta e o que você pode ajustar em setup.go. ## Config ```go type Config struct { Addr string // ":3000"; PORT/ADDR no ambiente Env Env // Dev | Prod; TRILHA_ENV MaxBodyBytes int64 // 1 MiB Logger *slog.Logger // slog.Default() Public fs.FS // arquivos estáticos; nil desliga Mounts map[string]fs.FS // árvores estáticas por prefixo de URL, antes de Public CSRFForAPI bool // exigir CSRF também em route.go CSRF CSRF // nomes do cookie, do campo e do cabeçalho do token BasePath string // prefixo de URL; TRILHA_BASE_PATH Security Security // cabeçalhos (veja Segurança) TrustedProxies []string // CIDRs; TRILHA_TRUSTED_PROXIES RateLimit RateLimit // limite global por cliente Secret, PreviousSecret []byte // TRILHA_SECRET, TRILHA_SECRET_PREVIOUS Timeouts Timeouts // limites do http.Server (trilha.NoTimeout desliga um) StaticCacheControl string // Cache-Control dos estáticos em prod ("public, max-age=3600") StaticHeaders func(name string, hdr http.Header) // cabeçalhos por arquivo estático LogRequest func(c *Ctx, status int, dur time.Duration) bool // nil loga todas OnSecurityEvent func(SecurityEvent) DevReload string // trilha.Off desliga o script de recarga em dev; TRILHA_DEV_RELOAD=off Observability Observability // sondas de saúde e o endereço de métricas CORS CORS // origens que podem chamar o app (zero = desligado) } ``` `trilha.ConfigFromEnv()` lê as variáveis; `trilha.PublicFS(embutido, "public")` escolhe entre a cópia embutida (prod) e a pasta no disco (dev). ### Onde configurar O arquivo gerado faz `cfg := trilha.ConfigFromEnv()`, chama `app.Config(&cfg)` se `app/setup.go` exportar `func Config(cfg *trilha.Config)`, e então `trilha.New(cfg)` e `app.Setup(a)`. `Config` também pode ser escrita como `func Config(cfg *trilha.Config) error`, e aí o arquivo gerado interrompe a subida com a sua mensagem — ler a configuração do próprio app é a operação que mais falha ao subir, e ela precisa poder falhar onde acontece. Você pode mexer em qualquer campo em qualquer um dos dois; a diferença é só *quando* o valor é lido: | Campos | Lidos em | `Config` | `Setup` (via `a.Config()`) | |---|---|---|---| | `Security`, `Public`, `MaxBodyBytes`, `CSRFForAPI`, `BasePath`, `OnSecurityEvent`, `StaticCacheControl`, `StaticHeaders` | a cada requisição | ✓ | ✓ | | `Logger`, `Secret`/`PreviousSecret`, `RateLimit`, `TrustedProxies`, `CORS` | derivados em `New` e **reaplicados** ao começar a servir (`ListenAndServe`, `Handler`, `Export`) | ✓ | ✓ | | `Addr`, `Timeouts` | `ListenAndServe` | ✓ | ✓ | | `Env` | `New` (chave efêmera em dev) e por requisição | ✓ | parcial | Use `Config` quando quiser montar a configuração a partir do seu próprio pacote (arquivo, Vault, flags) em vez do ambiente. ### Nomes do CSRF O token anda com três nomes, e cada um deles é um padrão, não uma regra: | Campo | Padrão | |---|---| | `CSRF.Cookie` | `trilha_csrf` | | `CSRF.Field` | `_csrf` | | `CSRF.Header` | `X-CSRF-Token` | ```go cfg.CSRF = trilha.CSRF{Cookie: "billing_csrf", Field: "_billing_csrf", Header: "X-Billing-CSRF"} ``` Troque quando o app não estiver sozinho na página: montado dentro de um servidor que já escreve `_csrf`, dois campos escondidos com o mesmo nome chegam ao handler e o navegador manda o cookie que quiser. Campo vazio fica com o padrão, então trocar um é uma linha. O nome posto aqui é o que `CSRFInput`, `CSRFToken`, a verificação, a lista do CORS e o cliente de teste usam — não existe um segundo lugar para manter em dia. ### CORS `CORS` fica desligado enquanto `Origins` estiver vazio: nenhum cabeçalho novo, e o `OPTIONS` continua chegando ao roteador. | Campo | Papel | |---|---| | `Origins []string` | origens exatas (`https://app.exemplo.com`), ou a entrada única `"*"` | | `Methods []string` | padrão `GET, HEAD, POST, PUT, PATCH, DELETE` | | `Headers []string` | o que o cliente pode mandar; padrão `Content-Type, Authorization, X-CSRF-Token, Trilha-Fragment` | | `Expose []string` | cabeçalhos de resposta que o script da outra origem pode ler | | `Credentials bool` | libera cookie e `Authorization`; incompatível com `"*"` | | `MaxAge time.Duration` | quanto o navegador guarda o preflight; zero omite o cabeçalho | Política insegura ou malformada entra em pânico no `New` (`"*"` com `Credentials`, `"*"` misturado com outras origens, origem com caminho, barra no fim ou sem esquema). O porquê está em [Segurança](/trilha/pt/aprender/seguranca). ### Timeouts `Timeouts.Shutdown` (5 s) é quanto `ListenAndServe` espera as requisições em andamento após `SIGINT`/`SIGTERM`. Zero significa "padrão"; `trilha.NoTimeout` desliga o limite (uploads grandes em rede lenta, long polling). `Write` vale para a resposta inteira: em vez de desligá-lo globalmente, uma rota que transmite deve usar `c.Stream()` (SSE) ou `c.NoWriteDeadline()`. ```go func Config(cfg *trilha.Config) { cfg.Timeouts.Read = trilha.NoTimeout // uploads de 32 MB do celular } ``` ### Estáticos `StaticCacheControl` troca o `Cache-Control` de produção (dev sempre manda `no-cache`). `StaticHeaders(nome, cabeçalhos)` roda depois, por arquivo, e pode mudar qualquer cabeçalho: ```go cfg.StaticCacheControl = "public, max-age=31536000, immutable" // seguro com c.Asset cfg.StaticHeaders = func(name string, h http.Header) { if name == "robots.txt" { h.Set("Cache-Control", "no-store") } h.Set("Cross-Origin-Resource-Policy", "same-origin") } ``` ### Árvores estáticas fora de `public/` `Public` serve uma árvore só, na raiz, o que exige que as pastas no disco tenham o formato das URLs. Quando não têm — um gerador de ícones que escreve em outro lugar, uma pasta compartilhada com outro build — `Mounts` liga prefixo a árvore: ```go cfg.Mounts = map[string]fs.FS{ "/icones/": sub(embutidos, "static/publico/icons"), "/js/": sub(embutidos, "static/js"), } ``` As montagens são tentadas antes de `Public`, do prefixo mais longo para o mais curto; um prefixo que casa sem ter o arquivo cai na próxima e depois em `Public`, então nenhuma precisa ser exaustiva. `StaticCacheControl`, `StaticHeaders` e `Asset` tratam um arquivo montado como qualquer outro, e o `name` que chega ao `StaticHeaders` é o da URL (`icones/icon-192.png`), que é o que distingue uma montagem da outra. ### O log de requisição Toda requisição casada por rota é logada. Num app que serve os próprios estáticos, a maior parte desse volume diz "um arquivo foi servido com 200" — e log que ninguém lê não protege ninguém. `LogRequest` decide, com a resposta já pronta: ```go cfg.LogRequest = func(c *trilha.Ctx, status int, _ time.Duration) bool { return status >= 400 || c.Pattern() != "" } ``` Serve também para "não logar health check" e "amostrar 1% do tráfego". Arquivo servido por `Public` ou por `Mounts` nunca passou por esse log. O registro traz os dois endereços: `path` é o concreto (`/v/cmtk…/orcamento`), para quem investiga um caso, e `route` é o gabarito (`/v/{viagemId}/orcamento`), para quem agrega. Um app com id na URL tem um `path` por registro e um `route` por tela, e reconstruir o segundo a partir do primeiro por expressão regular fora do app é o problema de cardinalidade que esse campo existe para evitar. O [`c.Pattern()`](/trilha/pt/referencia/ctx) é o mesmo valor dentro do handler, e é vazio para o que o fallback respondeu — que é o corte usado no exemplo acima para manter os estáticos fora do log. ### Versão no endereço (`Asset`) ```go func (a *App) Asset(path string) string func (c *Ctx) Asset(path string) string // idem, é o que o layout usa ``` `c.Asset("/site.css")` devolve `/site.css?v=8f3a1c92`, onde `v` é o hash FNV-1a do conteúdo do arquivo em `Config.Public` (com o prefixo de `BasePath`, como `c.Base()`). Como o endereço muda quando o arquivo muda, um deploy nunca deixa alguém com HTML novo e CSS antigo — o navegador pede uma URL que ele nunca viu. Um pedido cujo `v` confere recebe `public, max-age=31536000, immutable`, seja qual for o `StaticCacheControl`; um `v` errado ou ausente cai na regra normal, e em `dev` nada é imutável. O arquivo é lido uma vez em produção; em `dev` um `Stat` decide se relê, então editar o CSS e atualizar a página basta. Caminho que não existe em `Public` volta sem versão, com um aviso no log: um erro de digitação no layout não derruba a página. `ui.Head` e os exemplos já usam `Asset`. ## App | Método | Descrição | |---|---| | `New(cfg) *App` | cria a aplicação | | `Register(Route)` | registra uma rota (chamado pelo arquivo gerado) | | `SetRootLayout`, `SetNotFound`, `SetErrorPage` | ligam os arquivos da raiz | | `trilha.Provide[T](a, v)` | guarda uma dependência sob o tipo dela (veja "Dependências") | | `trilha.Use[T](b) T` | lê de volta, a partir de um `*Ctx` ou do `*App` | | `Values() map[string]any` | valores globais definidos em `Setup`, por nome e sem tipo | | `Logger() *slog.Logger` | o logger | | `Env() Env` | ambiente | | `Handler() http.Handler` | o mux raiz, para testes e para embutir em outro servidor | | `ListenAndServe() error` | serve com desligamento gracioso em SIGINT/SIGTERM; depois roda os ganchos de `OnShutdown` | | `OnShutdown(func(*App) error)` | registra o que fechar ao encerrar (pool, fila, flush); `setup.go` pode exportar `Shutdown`, que o arquivo gerado registra | | `Routes() map[string][]string` | padrões registrados e seus métodos | | `AddExportPath(paths...)` | caminhos extras para `Export`; último segmento com ponto exporta como o arquivo, não como `index.html` | | `ExportPaths() []string` | o que `Export` vai renderizar | | `Export(dir) error` | escreve o site estático | | `BasePath() string` | prefixo de URL | | `Security() *Security` | cabeçalhos, ajustáveis em `Setup` | | `Config() *Config` | a configuração inteira, ajustável em `Setup` (veja "Onde configurar") | `trilha.Run(a)` é o que o `main` gerado chama: exporta se `TRILHA_EXPORT` estiver definido, senão serve. `trilha.Fatal(err)` registra e encerra, ignorando `http.ErrServerClosed`. ### Dependências Uma página precisa do store, do pool, do mailer. Guardar isso em variáveis de pacote funciona até o dia em que existem dois apps no mesmo processo — um hospedeiro que monta dois, ou um teste que constrói o segundo — e aí os dois leem as mesmas globais, e o segundo teste a rodar enxerga os dados do primeiro. ```go func Setup(a *trilha.App) error { store := posts.New() trilha.Provide(a, store) return nil } ``` ```go func Page(c *trilha.Ctx) (h.Node, error) { store := trilha.Use[*posts.Store](c) ... } ``` `Provide` guarda o valor sob o tipo dele; `Use[T]` lê de volta, e aceita tanto o `*Ctx` de um handler quanto o próprio `*App` — que é o que `Setup` e um teste têm na mão. Um tipo que ninguém proveu estoura na chamada, dizendo qual tipo é, em vez de aparecer depois como um nil em outro lugar. O tipo é a chave, então uma costura se declara escrevendo o tipo: `trilha.Provide[Mailer](a, SMTPMailer{...})` guarda uma interface, e o handler que pede `Use[Mailer](c)` nunca fica sabendo qual implementação recebeu. Sem o argumento de tipo a chave seria `SMTPMailer`, e o handler estaria pedindo outra coisa. `Values()` continua ali para cola por nome, e `c.Get`/`c.Set` são os valores por requisição que um middleware deixa para trás — outra pergunta, respondida em [Middleware](/trilha/pt/aprender/middleware). ### `main` próprio Se algum arquivo do pacote `main` do projeto já declara `func main()`, o gerador omite o dele e escreve só `newApp()`. Você fica com o controle do ciclo de vida: ```go func main() { a := newApp() if err := migrar(a); err != nil { // entre o Setup e o servidor trilha.Fatal(err) } trilha.Run(a) } ``` `public/` é opcional: o `//go:embed` só é gerado quando a pasta tem arquivos. ### Um app dentro de outro binário Quando a pasta declara um pacote diferente de `main`, o arquivo gerado acompanha e exporta o construtor: ```go // internal/crm/trilha_gen.go → package crm, func NewApp() *trilha.App mux := http.NewServeMux() mux.HandleFunc("/legado", legado.Handler) mux.Handle("/", crm.NewApp().Handler()) http.ListenAndServe(":8080", mux) ``` O `Handler()` devolve o `http.Handler` do app inteiro — roteamento, estáticos, middlewares e páginas de erro — então o hospedeiro monta como monta qualquer handler. O `trilha gen` não precisa de nada além do pacote que a pasta já declara; veja [CLI](/trilha/pt/referencia/cli#um-app-dentro-de-um-binario-que-ja-existe). ## Testar um app O arquivo gerado define `newApp()`, e o `package trilha` traz o cliente de teste, então um teste no pacote `main` do projeto passa pelo app de verdade sem encanamento próprio: ```go func TestHome(t *testing.T) { trilha.TestRequest(t, newApp(), "GET", "/").WantStatus(200).WantContains("<h1>") } ``` | Símbolo | Papel | |---|---| | `TestingT` | `Helper()` e `Fatalf(...)`: o que os auxiliares usam de `*testing.T`, para o pacote nunca importar `testing` | | `TestRequest(t, a *App, method, target string, opts ...TestOption) *TestResponse` | um pedido no app inteiro | | `TestRoute(t, r Route, method, target string, opts ...TestOption) *TestResponse` | um `route.go`, com seus middlewares | | `TestPage(t, r Route, target string, opts ...TestOption) *TestResponse` | uma página, com seus layouts; o `Node` vem preenchido | | `NewTestClient(t, a *App) *TestClient` | o cliente com pote de cookies | | `(*TestClient) Request / Get / PostForm / PostJSON` | os pedidos | | `TestOption` | `WithApp`, `WithHeader`, `WithCookie`, `WithSigned`, `WithForm`, `WithJSON`, `WithBody`, `WithoutCSRF` | | `TestResponse` | `Node`, `WantStatus`, `WantContains`, `WantHeader`, `JSON(&v)`, `Cookie(nome)`; embute o `*httptest.ResponseRecorder` | Todo pedido leva o cookie do CSRF e, num método com corpo, o cabeçalho `X-CSRF-Token` correspondente: cookie e token vêm do mesmo cliente, que é exatamente o que o duplo envio pede de um navegador. O `WithoutCSRF()` é como um teste prova a recusa. Nenhuma asserção devolve `error` — em teste, o valor de um erro é parar com a mensagem certa, então a falha imprime o alvo, o status e o corpo. O que as asserções prontas não cobrem é um `if` sobre o recorder embutido. Veja [Testes](/trilha/pt/aprender/testes) para a trilha inteira. --- # Segurança Source: /trilha/pt/referencia/seguranca Configuração completa de cabeçalhos, proxies, limite de taxa, cookies assinados e eventos. ## Config.Security | Campo | Padrão | Cabeçalho | |---|---|---| | `CSP` | política com nonce (abaixo) | `Content-Security-Policy` | | `CSPExtra map[string][]string` | — | acrescenta origens a diretivas da política padrão | | `HSTS` | `max-age=31536000; includeSubDomains` (só em HTTPS) | `Strict-Transport-Security` | | `PermissionsPolicy` | `camera=(), microphone=(), geolocation=(), payment=(), usb=()` | `Permissions-Policy` | | `COOP` | `same-origin` | `Cross-Origin-Opener-Policy` | | `FrameOptions` | `DENY` | `X-Frame-Options` | | `Referrer` | `strict-origin-when-cross-origin` | `Referrer-Policy` | `trilha.Off` em qualquer campo remove o cabeçalho. `X-Content-Type-Options: nosniff` é sempre enviado. Política padrão: ```text default-src 'self'; script-src 'self' 'nonce-…'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self' ``` `c.Nonce()` devolve o nonce da requisição; `trilha.NonceAttr(c)` o coloca em um `h.Script`. `trilha.NonceFrom(r)` dá o mesmo valor para quem só tem o `*http.Request` — `html/template`, `templ`, um handler seu —, então a casca de um app em migração não precisa de um middleware próprio para alcançá-lo. Ajuste em `Setup` por `a.Security()`. ### Quando a resposta é do hospedeiro Um app montado dentro de um servidor que já responde pelas próprias respostas tem dois cabeçalhos a mais, não um: o hospedeiro escreveu a política, e o app escreve de novo. | Campo | Efeito | |---|---| | `Delegated bool` | não escreve cabeçalho nenhum — nem os seis que têm `Off`, nem o `nosniff` que não tem | | `Nonce func(*http.Request) string` | o nonce vem do hospedeiro, uma chamada por requisição que pedir | ```go a.Security().Delegated = true a.Security().Nonce = func(r *http.Request) string { return host.NonceOf(r) } ``` `Delegated` é uma decisão, não um padrão: o valor zero escreve os cabeçalhos, então um `Security{...}` escrito à mão nunca os desliga por omissão. O boot registra a delegação uma vez no log, porque resposta sem cabeçalho precisa aparecer em algum lugar. Sem `Nonce`, `c.Nonce()` inventa um valor por requisição — o que está certo para um app que publica a própria CSP e errado para um que não publica: a política do hospedeiro nunca ouviu falar daquele nonce, e o navegador recusa o script. Com `Nonce` devolvendo string vazia, `trilha.NonceAttr(c)` não renderiza atributo nenhum em vez de `nonce=""`. ## Proxies confiáveis `Config.TrustedProxies []string` (CIDR ou IP) ou `TRILHA_TRUSTED_PROXIES=a,b`. Efeitos quando o peer é confiável: `c.ClientIP()` lê `X-Forwarded-For` (o IP mais à direita que não seja proxy), `X-Forwarded-Proto: https` liga HSTS e marca cookies como `Secure`. ## Hosts permitidos `Config.AllowedHosts []string` ou `TRILHA_ALLOWED_HOSTS=a,b`. A requisição cujo `Host` não está na lista é respondida com 400 antes do roteador, das sondas e do CORS, e emite um evento `host`. Lista vazia = sem conferência. | Padrão | Libera | Não libera | |---|---|---| | `exemplo.com` | `exemplo.com`, `exemplo.com:8443`, `EXEMPLO.com.` | `sub.exemplo.com` | | `*.exemplo.com` | `app.exemplo.com` | `exemplo.com`, `a.b.exemplo.com` | Em `Dev`, `localhost`, `127.0.0.1` e `::1` passam sempre. O que se compara é o host que o app recebe — atrás de um proxy que reescreve o `Host`, liste o que o proxy manda. ## Limite de taxa `Config.RateLimit{RPS float64, Burst int}` aplica um *token bucket* por `ClientIP` antes dos middlewares. `trilha.Limit(rps, burst) MiddlewareFunc` cria um limitador independente para uma subárvore. Resposta: 429 com `Retry-After` (segundos) e evento `rate`. `trilha.ErrRateLimited` pode ser devolvido por um handler para o mesmo efeito. ## Cookies assinados | Símbolo | Descrição | |---|---| | `c.SetSigned(nome, valor, ttl) error` | grava cookie `valor|expira|hmac` com `HttpOnly`, `SameSite=Lax`, `Secure` em HTTPS; `ErrNoSecret` sem chave | | `c.Signed(nome) (string, bool)` | lê e verifica assinatura e prazo | | `c.ClearCookie(nome)` | expira um cookie | | `trilha.NewSigner(chaves...)`, `Sign`, `Verify` | o assinador (HMAC-SHA256) para uso direto | | `Config.Secret`, `Config.PreviousSecret` | `TRILHA_SECRET`, `TRILHA_SECRET_PREVIOUS` (base64 ou texto, ≥ 32 bytes) | Sem segredo: em `dev` uma chave efêmera é gerada (o `trilha dev` mantém uma por sessão); em `prod` o app avisa no log e `SetSigned` devolve `ErrNoSecret`. ## Timeouts `Config.Timeouts{ReadHeader 10s, Read 30s, Write 60s, Idle 120s, MaxHeaderBytes 64 KiB}`. Para respostas longas (SSE, download), chame `c.NoWriteDeadline()` antes de escrever. ## Eventos de segurança ```go type SecurityEvent struct { Kind string // csrf | auth | body | host | rate | panic Status int Method string Path string IP string RequestID string } ``` Registrados com `slog.Warn("security", ...)` e entregues a `Config.OnSecurityEvent`, uma vez por requisição. ## `trilha audit` Verifica: `TRILHA_SECRET`, `TRILHA_TRUSTED_PROXIES`, `trilha_gen.go` atualizado, versão do Go, `.gitignore`, `go vet` e `govulncheck` (`--no-vuln` para pular). Código de saída 1 com item crítico. `TRILHA_SECRET` ausente só é crítico quando o código assina alguma coisa — `SetSigned`, `Signed`, um `Signer` próprio, `Config.Secret` ou o pacote `auth`. Num app cuja sessão não é a do Trilha, vira aviso: um segredo que não assina nada entra no `.env`, no deploy e na rotação, e no dia em que alguém o girar não acontece nada — que é a pior coisa que um segredo pode ensinar. Definido e curto demais segue crítico nos dois casos: quem definiu quis usar. Ele também avisa da escrita que nenhum `Kind` alcança. Um `route.go` é API, e API não confere o token de CSRF, então uma rota de `POST` num app que também serve páginas quase sempre quer `var Kind = trilha.KindPage` num `kind.go` acima dela — uma linha para o ramo inteiro, veja [Convenções de arquivo](/trilha/pt/referencia/convencoes#o-kind-segue-a-subarvore). Ligar o `Config.CSRFForAPI` responde a mesma pergunta pelo outro lado e também cala o aviso. --- # Observabilidade Source: /trilha/pt/referencia/observabilidade Config.Observability, endpoints de saúde, registro de métricas, variáveis de ambiente e o contrato de cada resposta. ## Config.Observability | Campo | Padrão | O que faz | |---|---|---| | `Health string` | `/_trilha/health` | caminho base das sondas; `trilha.Off` remove | | `Metrics string` | `""` (desligado) | caminho da raspagem; vazio não registra endereço **nem instrumenta requisições** | | `Token string` | `TRILHA_OBS_TOKEN` | autoriza detalhe e métricas; **mínimo de 32 bytes**, comparado em tempo constante | | `Trusted []string` | — | CIDRs (ou IPs) que dispensam o token | | `Details string` | automático | `trilha.Off` nunca revela detalhe, nem para quem tem token; vazio = aberto em `dev`, autorizado em `prod` | | `Timeout time.Duration` | 2 s | prazo de cada verificação; `trilha.NoTimeout` desliga | | `CacheFor time.Duration` | 1 s | validade do resultado de prontidão; `trilha.NoTimeout` desliga o cache | Variáveis lidas por `ConfigFromEnv`: `TRILHA_OBS_TOKEN`, `TRILHA_METRICS`, `TRILHA_OBS_TRUSTED` (lista separada por vírgula). ## Endpoints | Método e caminho | Resposta | Status | |---|---|---| | `GET /_trilha/health/live` | `application/health+json` | sempre 200 | | `GET /_trilha/health/ready` | idem, roda as verificações | 200 ou 503 + `Retry-After: 5` | | `GET /_trilha/health` | igual a `ready` | 200 ou 503 | | `GET <Metrics>` | `text/plain; version=0.0.4` | 200, ou 401 sem autorização | Todas saem com `Cache-Control: no-store`, `X-Robots-Tag: noindex` e `X-Content-Type-Options: nosniff`. Outro método devolve 405 com `Allow: GET, HEAD`. As sondas correm **fora** da cadeia de middleware: sem CSRF, sem layout, sem limite de taxa (uma sonda de vida que tomasse 429 mataria um processo saudável) e registradas em nível `Debug`, para não afogar o log de auditoria. ## Verificações de prontidão ```go func (a *App) Check(name string, fn func(context.Context) error) func (a *App) HealthReport(ctx context.Context) HealthReport ``` ```go type HealthReport struct { Status string // "pass" | "fail" Checks []CheckResult UptimeSeconds float64 } type CheckResult struct { Name string Status string DurationMS float64 Error string } ``` `HealthReport` devolve tudo, sempre: é para o seu código (uma página de status interna, um portão de inicialização). Quem decide o que revelar é o endpoint. ## Registro de métricas ```go func (a *App) Metrics() *Metrics func (m *Metrics) Counter(name, help string, labels ...string) *Counter func (m *Metrics) Gauge(name, help string, labels ...string) *Gauge func (m *Metrics) Histogram(name, help string, buckets []float64, labels ...string) *Histogram ``` `MaxSeries` (mil por padrão) é o teto de combinações de rótulo por métrica; o excedente cai numa série com todos os rótulos em `other` e um aviso no log, uma única vez. | Tipo | Métodos | |---|---| | `*Counter` | `Inc()`, `Add(v)`, `With(valores...)` | | `*Gauge` | `Set(v)`, `Add(v)`, `Inc()`, `Dec()`, `With(valores...)` | | `*Histogram` | `Observe(v)`, `With(valores...)` | Nome inválido (fora de `[a-zA-Z_:][a-zA-Z0-9_:]*`) ou número errado de valores de rótulo causam `panic`: é erro de programação, aparece na primeira execução e não corrompe a saída. Chamar `Counter` duas vezes com o mesmo nome devolve a mesma série. `Histogram` com `buckets` nulo usa os padrões, em segundos: 0,001 0,005 0,01 0,025 0,05 0,1 0,25 0,5 1 2,5 5 10. ## Métricas do framework | Métrica | Tipo | Rótulos | |---|---|---| | `trilha_requests_total` | contador | `method`, `route`, `status` | | `trilha_request_duration_seconds` | histograma | `method`, `route` | | `trilha_requests_in_flight` | medidor | — | | `trilha_security_events_total` | contador | `kind` (`csrf`, `auth`, `body`, `rate`, `panic`) | | `trilha_panics_total` | contador | — | | `go_goroutines`, `go_memstats_alloc_bytes`, `go_memstats_sys_bytes` | medidores | — | | `go_gc_cycles_total` | contador | — | | `trilha_uptime_seconds` | medidor | — | | `trilha_build_info` | medidor (sempre 1) | `version`, `go_version` | `route` é o padrão registrado (`/blog/{slug}`). Estático, 404 e qualquer coisa fora do roteador entram como `other`. ## Correlação ```go func (c *Ctx) RequestID() string // X-Request-ID do cliente, ou gerado func (c *Ctx) TraceID() string // W3C traceparent; "" quando ausente ou malformado func (c *Ctx) Log() *slog.Logger // logger com request_id e trace_id ``` Um `traceparent` fora do formato é descartado em silêncio: valor escolhido por terceiro não entra no log como se fosse traço legítimo. ## O que a auditoria verifica `trilha audit` acrescenta três itens: token curto demais (crítico), métricas configuradas sem token nem rede confiável (crítico), `0.0.0.0/0` em `Trusted` (aviso) e ausência de qualquer `a.Check(` no projeto (aviso). --- # auth Source: /trilha/pt/referencia/auth Provider, Options, Auth, User e Store — a API do pacote auth, com os padrões e o que cada campo muda. `import "github.com/emersonjoe/trilha/auth"` — login OpenID Connect com a biblioteca padrão. O pacote não registra rota: expõe manipuladores que o seu `app/` publica. ## Provedores ```go func OIDC(issuer, clientID, clientSecret, redirectURL string) *Provider func EntraID(tenant, clientID, clientSecret, redirectURL string) *Provider func Keycloak(baseURL, realm, clientID, clientSecret, redirectURL string) *Provider func Cognito(region, userPoolID, clientID, clientSecret, redirectURL string) *Provider func Clerk(frontendAPI, clientID, clientSecret, redirectURL string) *Provider ``` | Construtor | Emissor resultante | Papéis lidos de | |---|---|---| | `OIDC` | o que você passar | `roles`, `groups` | | `EntraID` | `https://login.microsoftonline.com/<tenant>/v2.0` | `roles`, `groups`, `wids` | | `Keycloak` | `<baseURL>/realms/<realm>` | `realm_access.roles`, `resource_access[clientID].roles` | | `Cognito` | `https://cognito-idp.<region>.amazonaws.com/<userPoolID>` | `cognito:groups` | | `Clerk` | a Frontend API URL, normalizada (`https://<slug>.clerk.accounts.dev`) | `roles`, `groups` — o `id_token` do Clerk traz a organização (`org_id`), não o papel nela; uma claim configurada entra em `Options.RoleClaims` | `Provider.LogoutDomain` existe por causa do Cognito: aponte-o para o domínio de managed login (`<prefixo>.auth.<região>.amazoncognito.com`, ou o seu próprio) e o `Logout` redireciona para `/logout?client_id=…&logout_uri=…` lá; a URL de retorno precisa estar nas *Allowed sign-out URLs* do app client. Vazio, o `Logout` apaga a sessão local, diz isso no log e não finge que federou. Os outros provedores ignoram o campo. O Clerk também não publica `end_session_endpoint`, e não tem endereço equivalente: lá o `Logout` é sempre local, e o log diz que a sessão do Clerk ficou de pé. `Provider.HTTPClient` troca o cliente HTTP (padrão: 10 s de prazo). A descoberta é feita no primeiro uso e vale por uma hora; um emissor divergente entre a configuração e o documento é erro, não aviso. ## Options | Campo | Padrão | O que faz | |---|---|---| | `Scopes []string` | `openid profile email` | escopos pedidos ao provedor | | `Absolute time.Duration` | 8 h | prazo máximo da sessão, contado do login | | `Idle time.Duration` | 30 min | encerra sessão parada; `IdleOff: true` desliga | | `CookieName string` | `trilha_session` | nome do cookie de sessão | | `LoginPath string` | `/entrar` | para onde `Require` manda um navegador anônimo | | `AfterLogin string` | `/` | destino após o retorno, quando não há `next` | | `AfterLogout string` | `/` | destino após o logout | | `RoleClaims []string` | — | claims adicionais de onde ler papéis | | `Store Store` | `nil` | persiste a sessão; `nil` = cookie assinado, sem estado | ## Auth ```go func New(p *Provider, o Options) *Auth // não faz rede func (a *Auth) Start(c *trilha.Ctx) error // → provedor (PKCE, state, nonce) func (a *Auth) Callback(c *trilha.Ctx) error // valida o retorno e cria a sessão func (a *Auth) Logout(c *trilha.Ctx) error // apaga a sessão; RP-Initiated Logout quando existe func (a *Auth) Require() trilha.MiddlewareFunc func (a *Auth) RequireRole(roles ...string) trilha.MiddlewareFunc func (a *Auth) Optional() trilha.MiddlewareFunc func (a *Auth) User(c *trilha.Ctx) *User // nil quando anônimo func (a *Auth) Session(c *trilha.Ctx) (*User, error) ``` `Require` responde **302** para o login quando a requisição é uma navegação (Accept com `text/html`, fora de `/api/`) e **401** caso contrário. `RequireRole` responde **403** para quem está autenticado sem o papel. Basta **um** dos papéis listados; a comparação ignora maiúsculas. ## User ```go type User struct { Subject string // sub: o identificador estável Email string // email, ou preferred_username quando não há Name string Roles []string IssuedAt time.Time // momento do login ExpiresAt time.Time Seen time.Time // última atividade (janela de ociosidade) SessionID string // muda a cada login } func (u *User) HasRole(role string) bool ``` ## Store ```go type Store interface { Save(id string, u *User, ttl time.Duration) error Load(id string) (*User, bool) Delete(id string) error } func NewMemoryStore() *MemoryStore ``` Com um `Store` o cookie carrega apenas o identificador e o logout tem efeito imediato para todo mundo. `MemoryStore` vale para um processo só: réplicas não compartilham, e um reinício derruba todas as sessões. Para várias réplicas, implemente a interface sobre o seu banco ou cache. ## Cookies | Cookie | Validade | Conteúdo | |---|---|---| | `trilha_oidc_state` | 10 min | `state` do pedido em curso | | `trilha_oidc_nonce` | 10 min | `nonce` do pedido em curso | | `trilha_oidc_verifier` | 10 min | verificador PKCE | | `trilha_oidc_next` | 10 min | destino após o login (só caminho relativo) | | `trilha_session` | `Absolute` | a sessão (ou o id dela, com `Store`) | Todos são assinados (exigem `TRILHA_SECRET`), `HttpOnly`, `SameSite=Lax` e `Secure` sob HTTPS. Os quatro do fluxo são apagados no retorno, dê certo ou não. ## Algoritmos aceitos `RS256`, `RS384`, `RS512`, `ES256`, `ES384`. A lista é fixa: o `alg` do token não escolhe nada. Chaves RSA com módulo menor que 2048 bits são ignoradas no JWKS, o `kid` é obrigatório, e a tolerância de relógio é de 60 segundos. ## Auditoria `trilha audit` verifica, quando o projeto importa `trilha/auth`: segredo do cliente escrito no código (crítico) e `redirect_uri` em `http://` fora de `localhost` (crítico). --- # cache Source: /trilha/pt/referencia/cache Options, Key, Cache, Do, Get e Once — a API do pacote cache, com os padrões e o que cada campo muda. `import "github.com/emersonjoe/trilha/cache"` — cache em memória com prazo, tags e invalidação em lote, mais um memo por requisição. O pacote importa o runtime; o runtime não importa ele, então um app que nunca o menciona não carrega nada dele. ## Criando ```go func New(o Options) *Cache ``` | Campo de `Options` | Padrão | O que faz | |---|---|---| | `Name string` | `"cache"` | rótulo das séries de métrica; dê um nome a cada cache | | `MaxEntries int` | `10000` | teto; a entrada usada há mais tempo é despejada | | `Metrics *trilha.Metrics` | `nil` | registro onde publicar, em geral `a.Metrics()` | Não existe `Close`: nada roda em segundo plano. Uma entrada vencida sai quando é lida ou quando o teto a empurra, então um cache em que ninguém toca não custa nada além da memória que já ocupa. ## Chaves ```go type Key struct { Name string TTL time.Duration Tags []string } ``` `Name` é o endereço: nomes iguais são a mesma entrada, e tudo o que muda a resposta tem de estar nele. `TTL` zero ou menos é sem prazo. `Tags` agrupam entradas para o `Invalidate`; regravar uma entrada troca as tags dela, não as soma. ## Lendo e escrevendo ```go func (c *Cache) Set(k Key, v any) func (c *Cache) Get(name string) (any, bool) func (c *Cache) Delete(names ...string) int func (c *Cache) Invalidate(tags ...string) int func (c *Cache) Clear() func (c *Cache) Len() int func (c *Cache) Stats() Stats ``` `Delete` e `Invalidate` devolvem quantas entradas removeram. `Stats` traz `Hits`, `Misses`, `Evictions` e `Entries` — os mesmos quatro números das métricas, para uma página de saúde ou um teste. Todo método é seguro a partir de qualquer goroutine. ## Acesso tipado Go não permite parâmetro de tipo em método, então a metade tipada do pacote são funções de pacote: ```go func Get[T any](c *Cache, name string) (T, bool) func Do[T any](ctx context.Context, c *Cache, k Key, fn func(context.Context) (T, error)) (T, error) ``` `Get[T]` devolve o valor só quando o tipo guardado bate; valor escrito sob outro tipo é ausência, não pânico — um deploy que mudou uma struct não pode derrubar o app. `Do` devolve o valor guardado ou o produz com `fn`, guardando o resultado sob a `k`. O erro volta para quem chamou e não é guardado para ninguém. Só um `fn` roda por nome de cada vez: quem chega durante uma busca espera por ela e lê a mesma resposta, então a primeira requisição depois de um `Invalidate` não vira tropel. O mutex do cache não fica seguro durante o `fn`, então um `Do` dentro de outro funciona. ## Por requisição ```go func Once[T any](c *trilha.Ctx, name string, fn func() (T, error)) (T, error) ``` Responde a uma pergunta uma vez por requisição e a esquece com a resposta. Não é cache e não recebe `*Cache`: use para o que o layout, a página e três componentes precisam saber — quem está logado, acima de tudo — em vez de passar o valor por todas as assinaturas. O erro também fica guardado, então uma busca que falhou é tentada uma vez. Guardar no `*Cache`, sob um nome fixo, um valor que é de um usuário serve esse valor para o próximo visitante; o `Once` é o que não consegue fazer isso. ## Métricas Com `Options.Metrics` preenchido, quatro séries aparecem na exposição, rotuladas com `cache` no valor de `Options.Name`: | Série | Tipo | Significado | |---|---|---| | `trilha_cache_hits_total` | contador | leituras respondidas da memória | | `trilha_cache_misses_total` | contador | leituras que não acharam ou acharam vencido | | `trilha_cache_evictions_total` | contador | entradas derrubadas pelo teto | | `trilha_cache_entries` | medidor | entradas guardadas neste instante | Despejos subindo sem parar é `MaxEntries` baixo demais para o espaço de chaves em uso. --- # ui Source: /trilha/pt/referencia/ui Componentes do kit, variantes, assets e o contrato de tema. `import "github.com/emersonjoe/trilha/ui"` — só stdlib. Os componentes devolvem `h.Node` com classes `ui-*` de `public/ui.css`; comportamentos em `public/ui.js`. ## Assets | Símbolo | Papel | |---|---| | `ui.Head(c) h.Node` | `<link>` para `ui.theme.css` e `ui.css`, script inline (com nonce) que aplica o tema salvo, `<script defer src=ui.js>`; respeita `c.Base()` | | `ui.Body() h.Node` | classe `ui-body` para o `<body>` | | `ui.Asset(nome) []byte` | conteúdo embutido de `ui.css`, `ui.theme.css`, `ui.js`, `ui.nav.js` ou `ui.upload.js` | | `ui.Files` | os cinco nomes, na ordem em que `trilha ui` os grava | ## Variantes e tamanhos `ui.Secondary()`, `ui.Outline()`, `ui.Ghost()`, `ui.Destructive()`, `ui.LinkStyle()`, `ui.Sm()`, `ui.Lg()`, `ui.IconSize()`. São atributos de classe: valem em `Button`, `Submit`, `ButtonLink`, `Badge` e `Alert` (cada um traduz para a sua classe, ex. `ui-btn-outline`, `ui-badge-outline`). ## Componentes | Função | Renderiza | |---|---| | `Container, Stack, Row, Grid, Spacer` | layout: largura máxima, coluna, linha, grade responsiva | | `Header(children...)`, `Brand(href, nome)`, `Nav(...)`, `NavLink(href, rótulo, atual)`, `Sidebar(...)` | barra fixa no topo, marca, navegação (com `aria-current`), coluna lateral | | `H1, H2, H3, Lead, Muted, Code(s), Kbd(s)` | tipografia | | `Button, Submit, ButtonLink(href, ...)` | `<button type=button>`, `<button type=submit>`, `<a>` com cara de botão | | `Card, CardHeader, CardTitle(s), CardDescription(s), CardContent, CardFooter` | cartão | | `Input, Textarea, Select, Checkbox, Radio, Switch, Label` | controles (`Switch` tem `role=switch`) | | `Field(id, rótulo, controle, opts...)` | rótulo + controle + `Help(s)` + `Error(s)`; `With(nós...)` põe atributos no grupo | | `CheckRow(controle, rótulo, id)` | checkbox/switch ao lado do rótulo | | `Invalid()` | `aria-invalid="true"` (anel vermelho) | | `Errors(errs, campo)` | opção de `Field`: mostra a mensagem de `errs[campo]` (um `trilha.FieldErrors`) se houver | | `InvalidIf(errs, campo)` | `Invalid()` só quando há erro para o campo | | `SelectOptions([]Option{{Value, Label}}, selecionado)` | `<option>`s marcando o selecionado; `Value: ""` é placeholder (desabilitado) e fica selecionado quando nada casa | | `Checked(bool)` | `checked` condicional (ida e volta de checkbox/switch/radio) | | `ShowWhen(campo, valores...)` | `data-ui-show-when`: mostra o elemento só com o valor (ou qualquer valor não vazio); controles escondidos são desabilitados | | `Badge`, `Alert(título, ...)`, `AlertDescription(...)` | selo e aviso (`role=alert`) | | `Toaster(...)`, `Toast(tipo, texto, fadeMs)` | pilha de avisos; `tipo` = `""`, `success`, `error`; `fadeMs > 0` some sozinho | | `Flashes(c)` | o toaster com os avisos do [`c.Flash`](/trilha/pt/referencia/ctx) — ponha no layout; `FlashInfo`, `FlashSuccess` e `FlashError` são os tipos | | `Table(...)`, `Num()`, `Depth(n)` | tabela rolável; célula numérica; indentação de linha (árvore) | | `Tabs(id, Tab{Label, Content}...)` | abas acessíveis (setas, Home/End); a primeira começa aberta | | `Dialog(id, título, ...)`, `DialogDescription(s)`, `DialogFooter(...)`, `DialogTrigger(id, ...)`, `DialogClose(...)` | `<dialog>` nativo com `showModal` | | `Confirm(título, descrição)` | atributos para um `<form>`: o `ui.js` pergunta num diálogo antes de enviar, inclusive em formulário de fragmento. O botão que confirma repete o rótulo do botão apertado; o outro diz `Cancel`, ou o que estiver em `h.Data("ui-confirm-cancel", "…")`. Sem JavaScript o formulário envia direto | | `Menu(id, ...)`, `MenuItem(...)`, `MenuLink(href, ...)`, `MenuTrigger(id, ...)` | menu com o atributo `popover` nativo | | `Pagination(Pages{Page, Total, Href, Prev, Next, Label})` | navegação de páginas em links; a página atual é um `<span>` com `aria-current`, as pontas somem em vez de virarem link desabilitado, e uma janela de sete casas guarda a primeira e a última página com `…` sobre cada buraco; uma página só não desenha nada | | `Tooltip(texto, ...)` | dica no que ele embrulha: `title` mais `data-ui-tooltip`, promovido pelo `ui.js` a uma bolha com `role=tooltip` e `aria-describedby` | | `Separator, Skeleton, Progress(valor, máx), Breadcrumb(Crumb{Label, Href}...), Avatar(iniciais, src), Collapsible(resumo, ...)` | diversos | | `ThemeToggle()` | botão que alterna claro/escuro (`localStorage["ui-theme"]`) | | `Swap(id)` | `data-trilha-target`: o `<a>` ou `<form>` pede só o elemento `#id` e troca (fragmentos) | | `NoPush()` | `data-trilha-push="false"`: a troca não mexe no histórico | | `Icon(nome, attrs...)`, `Icons()` | SVG inline do Lucide; nome desconhecido → pânico (erro de programação) | ## ui.js Tudo por atributo, sem inicialização: `[data-ui-tabs]`, `[data-ui-dialog-open=id]`, `[data-ui-dialog-close]`, `[data-ui-fade=ms]`, `[data-ui-show-when]`, `[data-ui-toast=texto]` (`data-ui-toast-kind`), `[data-ui-theme-toggle]`, `[data-ui-tooltip=texto]`, `[popover].ui-menu`. Também expõe `window.ui.toast(texto, {kind, ms})`, `ui.fade(el)`, `ui.evalShowWhen(root)` e `ui.applyTheme("dark"|"light")`. Elementos inseridos depois (HTMX, fetch) precisam de `ui.evalShowWhen(el)`/`ui.fade(el)`/`ui.initTooltips(el)` se usarem esses atributos — `ui.hydrate(el)` faz os três de uma vez. ## Fragmentos `[data-trilha-target=id]` em `<a>` ou `<form>` (veja `ui.Swap`) faz o kit pedir a mesma URL com o cabeçalho `Trilha-Fragment` e trocar o elemento `#id` pelo HTML que voltou. Detalhes: o alvo ganha `aria-busy` durante a espera; **204 com `Trilha-Location`** vira navegação de verdade; **422** põe o foco no primeiro `[aria-invalid=true]`, senão o foco (e o cursor) voltam para o campo em uso; o que entrou é hidratado (`fade`, `show-when`) e dispara `trilha:swap` (`detail.target`, `detail.status`). Em 5xx, erro de rede ou fragmento sem o id, o kit desiste e navega/envia normalmente. `ui.swap(id, html, status)` e `ui.hydrate(el)` fazem a troca à mão. Veja [Interatividade](/trilha/pt/aprender/interatividade). ## Navegação A navegação no cliente fica desligada até você pedir, em dois lugares: | Símbolo | Papel | |---|---| | `ui.Navigate(id) h.Node` | marca uma região: um clique em link da mesma origem dentro dela troca o elemento `#id` pelo mesmo elemento da próxima página. `id` vazio significa o próprio elemento marcado | | `ui.NoNavigate() h.Node` | deixa um link de fora (um download, outro app, uma rota que precisa recarregar) | | `ui.NavigateScript(c) h.Node` | `<script defer src=ui.nav.js>`; ponha uma vez, no layout da área que usa | O que o navegador continua fazendo: o endereço na barra é o mesmo de uma navegação normal, Voltar e Avançar funcionam (e restauram a rolagem da entrada para onde voltam), `Cmd`/`Ctrl`-clique e clique do meio abrem aba, e `target`, `download` e links para outra origem passam intactos. O que o kit acrescenta: `aria-busy` na região durante a espera, foco no que entrou, `ui.hydrate` e o evento `trilha:swap`, e uma requisição por vez — um segundo clique cancela a primeira. Em 5xx, erro de rede, redirecionamento ou página sem o id, ele desiste e navega de verdade. O comportamento é um arquivo separado para que um app que não use não o baixe, e o `ui.Head` não o carrega. Link marcado com `ui.Swap` continua sendo fragmento: ele pede um pedaço da página, não a próxima página. ## Upload com progresso Um formulário que manda arquivo é um formulário: `method="post"`, `enctype="multipart/form-data"`, o campo de CSRF. Três símbolos põem a barra de progresso em cima disso, e ela fica desligada até você pedir: | Símbolo | Papel | |---|---| | `ui.UploadTo(id) h.Node` | no `<form>`: envia por XHR e troca o `#id` pelo que voltar | | `ui.UploadBar(attrs…) h.Node` | o `<progress>` que o kit preenche; escondido até o envio começar | | `ui.UploadScript(c) h.Node` | `<script defer src=ui.upload.js>`, uma vez por página que envia | A requisição leva `Trilha-Fragment: id`, então o handler responde o pedaço com o mesmo `c.Fragment()` de sempre. Enquanto sobe, a barra recebe `value`/`max` do evento de progresso do próprio navegador (e perde o `value` — barra indeterminada — quando o total é desconhecido), e um evento `trilha:upload` sobe com `detail: {loaded, total, form}`. Em 5xx, erro de rede ou pedaço sem o id, o formulário envia de verdade: o usuário vê a página recarregar, não um botão que não fez nada. O atributo é `data-trilha-upload`, e não `data-trilha-target`, para o tratador de fragmento do `ui.js` não enviar o mesmo formulário uma segunda vez. O limite de corpo é assunto do servidor — veja [`AllowBody`](/trilha/pt/referencia/ctx). ## Tema `ui.theme.css` define, em `:root` e `.dark`, exatamente as variáveis do shadcn/ui v4: `--background/--foreground`, `--card/--card-foreground`, `--popover/…`, `--primary/…`, `--secondary/…`, `--muted/…`, `--accent/…`, `--destructive`, `--border`, `--input`, `--ring`, `--chart-1…5`, `--sidebar…`, `--radius`. `ui.css` deriva `--radius-sm/md/lg/xl`. O modo escuro é a classe `dark` no `<html>` (o script de `ui.Head` aplica a preferência salva ou a do sistema antes da primeira pintura). ## CLI `trilha ui [--force] [--css-only|--js-only]` grava os cinco arquivos em `public/`: `ui.theme.css` só é criado (nunca sobrescrito); `ui.css`, `ui.js`, `ui.nav.js` e `ui.upload.js` são atualizados quando iguais a uma versão anterior e, se você os editou, só com `--force`. --- # ai Source: /trilha/pt/referencia/ai Cliente OpenAI-compatível, ferramentas, agentes, handoffs e composição. `import "github.com/emersonjoe/trilha/ai"` — sem dependências externas. ## Client | Campo / função | Papel | |---|---| | `NewFromEnv() *Client` | lê `OPENAI_API_KEY`, `OPENAI_BASE_URL` (padrão `https://api.openai.com/v1`) e `TRILHA_AI_MODEL` (ou `OPENAI_MODEL`; padrão `gpt-4o-mini`) | | `BaseURL, APIKey, Model string` | configuração direta | | `Headers map[string]string` | cabeçalhos extras (OpenRouter, Azure...) | | `HTTPClient *http.Client` | cliente HTTP (padrão com timeout de 2 min) | | `Chat(ctx, Request) (*Response, error)` | uma chamada; `Response.Text()` e `Response.ToolCalls()` | | `Stream(ctx, Request, func(Delta) error) error` | resposta em pedaços; `Delta.Content`, `Delta.ToolCalls`, `Delta.Usage` no fim | Respostas não-2xx viram `*ai.Error{Status, Code, Message}`. ## Request e mensagens `Request{Model, Messages, Tools, ToolChoice, Temperature, MaxTokens, ResponseFormat, Extra}`. `Extra map[string]any` é mesclado no JSON enviado, para parâmetros específicos do provedor. `ResponseFormat{Type: "json_schema", JSONSchema: ...}` pede saída estruturada. Construtores: `ai.System(s)`, `ai.User(s)`, `ai.Assistant(s)`, `ai.ToolResult(callID, s)`. ## Tool ```go func NewTool(name, description string, schema json.RawMessage, fn ToolFunc) *Tool type ToolFunc func(ctx context.Context, args json.RawMessage) (string, error) func Schema(s string) json.RawMessage // valida o JSON; pânico no início se inválido func Typed[T any](fn func(ctx, in T) (string, error)) ToolFunc ``` `schema == nil` significa "sem argumentos". Erros e pânicos da função viram texto para o modelo (`error: ...`) e aparecem em `Step.Err`. ## Agent | Campo | Papel | |---|---| | `Name` | identifica o agente em `Step.Agent` e nos handoffs (`transfer_to_<slug>`) | | `Instructions` | mensagem `system` | | `Model` | substitui o modelo do cliente | | `Tools []*Tool` | ferramentas | | `Handoffs []*Agent` | agentes para os quais este pode transferir a conversa | | `MaxTurns` | limite de chamadas ao modelo por `Run` (padrão 10; excedido → `ErrMaxTurns`) | | `Temperature *float64`, `ResponseFormat` | passados em cada requisição | ```go func Run(ctx, cli *Client, agent *Agent, input string, history ...Message) (*Result, error) func RunStream(ctx, cli *Client, agent *Agent, input string, fn func(Event), history ...Message) (*Result, error) ``` `Result{Output, Agent, Messages, Steps, Usage, Turns}`. `Messages` serve de histórico para a próxima chamada (mensagens `system` do histórico são ignoradas; valem as do agente atual). `Event.Type`: `text` (`Text`), `tool_call` e `tool_result` (`Step`), `handoff` (`Step.HandoffTo`, `Agent` = novo agente), `done` (`Result`), `error` (`Err`). Ferramentas de uma mesma rodada rodam em paralelo; a ordem dos resultados no histórico é a ordem em que o modelo as pediu. Um handoff troca a mensagem `system`, mantém o histórico e continua o laço com o agente alvo. ## Composição ```go func (a *Agent) AsTool(cli *Client, description string) *Tool // {"input": "..."} → texto func Parallel(ctx, cli, input string, agents ...*Agent) ([]*Result, error) func Chain(ctx, cli, input string, agents ...*Agent) (*Result, error) ``` `Parallel` devolve na ordem dos agentes e propaga o primeiro erro; `Chain` passa `Output` de um como `input` do próximo. --- # mcp Source: /trilha/pt/referencia/mcp Cliente e servidor do Model Context Protocol (stdio e Streamable HTTP). `import "github.com/emersonjoe/trilha/ai/mcp"` — JSON-RPC 2.0, revisão `2025-03-26`, sem dependências externas. Cobre o recurso *tools* (listar e chamar). ## Cliente ```go func Dial(ctx, dial Dialer) (*Client, error) // abre o transporte e faz initialize func Stdio(name string, args ...string) Dialer // processo filho, JSON por linha func HTTP(url string, headers map[string]string) Dialer // Streamable HTTP (POST por mensagem) ``` | Método | Papel | |---|---| | `ListTools(ctx) ([]ToolInfo, error)` | segue a paginação (`nextCursor`) | | `CallTool(ctx, name, args) (CallResult, error)` | `CallResult.Text()` junta os itens de texto | | `Tools(ctx) ([]*ai.Tool, error)` | ferramentas prontas para um `ai.Agent`; `isError` vira erro | | `Server.Name/Version/ProtocolVersion` | preenchidos pelo `initialize` | | `Close()` | fecha o transporte e encerra o processo filho | O cliente HTTP guarda o `Mcp-Session-Id` recebido no `initialize` e o envia nas mensagens seguintes; aceita respostas JSON ou `text/event-stream`. ## Servidor ```go func NewServer(name, version string, tools ...*ai.Tool) *Server func (s *Server) ServeHTTP(c *trilha.Ctx) error // em app/.../route.go: POST func (s *Server) Handler() http.Handler // fora do Trilha func (s *Server) ServeStdio(ctx, r io.Reader, w io.Writer) error ``` Métodos atendidos: `initialize`, `ping`, `tools/list`, `tools/call`; notificações são aceitas sem resposta (`202`). Em HTTP, `initialize` emite `Mcp-Session-Id`; mensagens sem sessão válida recebem `404`; sessões expiram após `SessionTTL` (1 h) sem uso. Só `POST` é aceito (`405` com `Allow: POST` para o resto). Corpo limitado a 4 MiB. Erros e pânicos de ferramentas viram resultado com `isError: true`, como manda o protocolo; ferramenta desconhecida é erro JSON-RPC `-32602`. ## Transporte próprio `Transport` é uma interface (`Send`, `Recv`, `Close`). `Pipe(r, w, closer)` monta o transporte de linha sobre qualquer par leitor/escritor, o que os testes usam com `io.Pipe`. --- # CLI Source: /trilha/pt/referencia/cli Os comandos de trilha e suas opções. ```text trilha new <dir> [--module caminho] [--lang en|pt] [--agents] [--trilha-dir ../trilha] [--no-tidy] trilha gen [--check] [--package nome] trilha generate page|route|test <url> | component <Nome> [--methods GET,POST] [--bind Tipo] [--form Tipo] [--layout arquivo] [--force] [--dir caminho] [--lang en|pt] trilha dev [--addr :3000] trilha build [-o bin/<nome>] trilha export [-o out] [--base /prefixo] trilha openapi [-o arquivo] [--title T] [--version V] [--server URL] [--check] trilha routes trilha check [--json] [--fix] trilha ctx [--json] [--routes|--types|--all] trilha audit [--no-vuln] trilha ui [--force] [--css-only|--js-only] trilha agents [--force] [--lang en|pt] trilha version ``` | Comando | O que faz | |---|---| | `new` | cria um projeto com `go.mod`, layout, página inicial, 404, uma rota de API, `public/style.css` e `.gitignore`; roda `go mod tidy` e `gen` | | `gen` | varre `app/` e escreve `trilha_gen.go`; falha com uma linha por convenção violada | | `generate` | grava um esqueleto — página, rota de API ou componente — na pasta que a convenção pede | | `dev` | `gen` + `go build` + executa o app em uma porta interna + proxy em `--addr` + recarga por SSE + inspetor de rotas em `/_trilha/routes` | | `build` | `gen` + `go build -trimpath -ldflags="-s -w"` com `CGO_ENABLED=0` | | `export` | `gen` + `go build` + executa com `TRILHA_EXPORT` para gerar HTML estático | | `openapi` | escreve o documento OpenAPI 3.1 das rotas de API (`-o -` na saída padrão) | | `routes` | imprime `MÉTODOS PADRÃO ORIGEM` para cada rota | | `check` | o portão único: `gen`, `gofmt`, `vet`, `test`, `audit` e `openapi`, nesta ordem, parando na primeira falha | | `ctx` | o mapa do projeto — rotas, API, tipos, setup — numa leitura só, em Markdown ou JSON | | `audit` | checklist de segurança antes de publicar (veja [Segurança](/trilha/pt/referencia/seguranca)) | | `agents` | grava `AGENTS.md` e `CLAUDE.md` para um agente de código achar as convenções | Os comandos rodam na pasta que contém `app/`. O caminho de import do projeto vem do `go.mod` mais próximo, mais a subpasta, então um app pode viver dentro de um módulo maior. ## Idioma As mensagens da CLI seguem `TRILHA_LANG`, depois `LC_ALL`, `LC_MESSAGES` e `LANG`: um valor começando com `pt` (em qualquer caixa) seleciona português; qualquer outro, inclusive variável indefinida, seleciona inglês. As mensagens do runtime, do scanner e do gerador (as que acabam no seu código e nos seus logs) são sempre em inglês. `trilha new --lang en|pt` escolhe o idioma dos textos gerados (página inicial, 404, `<html lang>`); o padrão é o idioma da CLI. ## trilha dev Além do proxy e da recarga, o supervisor serve o inspetor de rotas em `/_trilha/routes`: a tabela de rotas em ordem de precedência com layouts e middlewares de cada uma, e uma caixa que responde qual padrão atenderia um caminho. A página é do supervisor, não do app, então ela não existe no binário que o `trilha build` produz — veja [Dev e produção](/trilha/pt/aprender/dev-e-producao#o-inspetor-de-rotas). ## trilha generate A convenção é o que custa lembrar: que `/blog/{slug}` mora em `app/blog/slug_/`, que uma pasta catch-all termina em `__`, que um grupo termina em `-`. O `generate` recebe a URL e faz a tradução: ```bash trilha generate page /blog/{slug} # app/blog/slug_/page.go trilha generate route /api/itens/{id} # app/api/itens/id_/route.go trilha generate component Aviso # internal/components/aviso.go ``` A página e a rota saem compilando, com `c.Param` já lendo cada parâmetro, e o `trilha_gen.go` é regerado no fim — a URL responde antes de você abrir o editor. Um componente é uma função que devolve `h.Node`, então compõe como qualquer outra; `--dir` põe em outro lugar (`internal/icones`, por exemplo). O nome do pacote é o que já está declarado na pasta, quando existe; senão vem do nome da pasta (`slug_` → `slug`, `relatorio.csv` → `relatoriocsv`, `type` → `type_`). Um arquivo existente não é sobrescrito sem `--force`, e o `--force` não cobre a única recusa que é convenção: uma pasta responde ou uma página ou uma rota, nunca as duas. ### O contrato, não só a pasta Sem flags o esqueleto é genérico, e o que sobra para escrever — a struct, o `Bind`, a validação, a resposta, o teste — é justamente onde se erra assinatura. As flags escrevem essa parte: ```bash trilha generate route /api/posts/{id}/comments --methods GET,POST --bind Comment trilha generate page /contato --form Contact --layout app/layout.go trilha generate test /api/posts ``` - `--methods` escreve um handler por método, na assinatura que o scanner lê, com o `c.Param("id")` já pronto para cada parâmetro do caminho. - `--bind Tipo` faz os métodos com corpo chamarem `c.BindJSON(&in)`: devolver esse erro já é o 422 com os campos, não sobra tratamento. Um tipo que o projeto já declara é importado de onde está; um que não existe nasce no pacote da rota com tags `json` e `validate` de exemplo. Um nome declarado em dois pacotes é recusa, e a mensagem manda escrever `posts.Comment`. - `--form Tipo` numa página escreve a ida e volta inteira: `trilha.CSRFInput`, um `ui.Field` por campo com a mensagem ao lado, 422 com `trilha.FieldErrors` quando o `Bind` recusa e `POST → redirect → GET` quando ele aceita. - `--layout <arquivo>` grava o `layout.go` que falta acima da página. Um caminho que não envolve a página é recusado: o scanner nunca o aplicaria, e descobrir isso custa uma ida e volta. - `generate test <url>` escreve o teste ao lado da rota, no pacote dela, com um caso por método que o scanner encontra — e um corpo montado das tags quando dá para ler o tipo que o handler lê. Logo depois de gerar, o `trilha check` fica verde sem ninguém editar nada. O `--lang en|pt` escolhe o idioma dos comentários do esqueleto; identificadores, nomes de campo e mensagens de erro seguem em inglês. ## trilha ui Grava ou atualiza o kit de interface em `public/`: `ui.theme.css` (só criado; é o seu tema), `ui.css` e `ui.js` (atualizados; se editados localmente, só com `--force`). `--css-only` e `--js-only` limitam o que é tocado. `trilha new` roda o mesmo passo. Veja [Interface com ui](/trilha/pt/aprender/interface-com-ui). ## trilha agents Grava dois arquivos na raiz do projeto, e só quando é pedido: suporte a agentes de código é opt-in, então `trilha new` sozinho não deixa nenhum dos dois. `trilha new --agents` já os cria junto com o projeto. | Arquivo | De quem é | |---|---| | `AGENTS.md` | do framework: as convenções, os comandos e o que não fazer | | `CLAUDE.md` | seu: três linhas apontando para o `AGENTS.md`, mais o que este repositório pedir | O `AGENTS.md` leva um carimbo com o hash do próprio corpo, a mesma regra do kit ui. Uma cópia intocada de uma versão anterior é atualizada em silêncio na próxima rodada; uma que você editou só é sobrescrita com `--force`, e sem ele o comando para e avisa. O `CLAUDE.md` nunca é sobrescrito. `--lang en|pt` escolhe a língua dos dois arquivos e por padrão é a da CLI. Rode de novo depois de atualizar a CLI: o `AGENTS.md` nomeia os comandos da versão que o gravou, então uma cópia de uma release anterior continua mandando o agente para comandos que foram substituídos. A sequência inteira para um projeto vindo de versão anterior está na [receita de migração](/trilha/pt/receitas/migracao#ligar-os-arquivos-de-agente-num-projeto-que-ja-existe). ## trilha openapi Lê `app/`, deduz o documento a partir dos handlers e escreve `openapi.json`. `-o -` escreve na saída padrão; `--title`, `--version` e `--server` preenchem o que o código não tem como saber (o padrão é o nome do módulo, `0.0.0` e nenhum servidor). `--check` compara com o arquivo no disco e sai com `1` quando divergem — a mesma linha que o `gen --check` é, pelo mesmo motivo: ```yaml - run: trilha openapi --check ``` O que é deduzido e as diretivas `openapi:` estão em [APIs](/trilha/pt/aprender/api#documento-openapi). ## trilha check Seis portões num comando, na ordem que falha mais barato primeiro: `gen`, `gofmt`, `vet`, `test`, `audit` (sem a varredura de vulnerabilidades, que precisa de rede) e `openapi` (só se o projeto guarda o documento). Ele para na primeira falha — o que vem depois de uma compilação quebrada não diz nada sobre o projeto — e os passos que não rodaram dizem isso: ```text ✓ gen ✗ gofmt (failed) app/blog/page.go: not gofmt'd → run gofmt -w (or trilha check --fix) - vet (not run) - test (not run) - audit (not run) - openapi (not run) ``` Todo problema vem com o arquivo, a linha e a frase que resolve. O `--fix` regrava o `trilha_gen.go` e a formatação antes de julgá-los, e aí o passo reporta `fixed`. O `--json` escreve o relatório que uma ferramenta lê, com os mesmos campos: ```json { "ok": false, "steps": [{ "tool": "gen", "status": "failed" }], "problems": [ { "tool": "gen", "file": "app/page.go", "line": 3, "message": "page.go must export func Page(c *trilha.Ctx) (h.Node, error); found func Render", "fix": "rename the function to Page, or delete page.go if this directory is not a page" } ] } ``` Sai com `1` quando algo falhou, então no CI é a linha única: ```yaml - run: trilha check ``` ## trilha ctx O mapa do projeto numa leitura só: o módulo, se o `trilha_gen.go` está em dia, cada rota com seu arquivo, métodos, parâmetros, layouts e middlewares, cada operação de API com sua query, corpo e respostas, os tipos que essas operações trocam e o que o `app/setup.go` provê: ```text # example.com/loja - trilha 0.37.0 · 8 routes (6 pages, 2 APIs) - trilha_gen.go: up to date - app/setup.go: Setup, Config ## Routes - `GET /` — app/page.go · layouts: app/layout.go ... ``` O padrão é Markdown compacto, para ler. `--routes` e `--types` imprimem uma seção sozinha, `--all` não elide nada (os middlewares por método, todas as respostas de erro, o tipo `Problem`) e `--json` escreve o mesmo modelo como documento, ordenado e sem relógio nem caminho absoluto, de modo que duas execuções na mesma árvore dão os mesmos bytes. A seção de API e os tipos saem da mesma inferência que está por trás do `trilha openapi`, então o mapa e o documento não têm como divergir. Como o `openapi.json`, a saída é um documento de máquina e não é traduzida. ## trilha gen --check Gera em memória, compara com o `trilha_gen.go` commitado e sai com `1` mostrando as linhas que divergem — uma linha no CI, e uma pasta nova em `app/` sem `trilha gen` depois deixa de ser um 404 que ninguém explica: ```yaml - run: trilha gen --check ``` O `trilha check` faz essa mesma comparação no primeiro portão, e é por isso que um projeto que usa o `check` não precisa de uma linha `gen --check` à parte. O `trilha audit` faz a comparação como aviso, e ainda compara a versão da CLI com a da biblioteca no `go.mod`: uma CLI mais nova escreve código que a biblioteca pode ainda não ter, e o erro aparece dentro de código gerado — o pior lugar para procurar. ## Arquivo gerado `trilha_gen.go` é determinístico (mesma árvore, mesmos bytes), tem o cabeçalho `// Code generated by trilha. DO NOT EDIT.`, mais a diretiva `//go:generate trilha gen` (para `go generate ./...` funcionar sem ninguém precisar saber o nome da ferramenta), e deve ser commitado: `go build ./...` funciona sem a CLI instalada. Ele define `newApp() *trilha.App` e `main()`; se outro arquivo do pacote já tem `func main()`, o gerador omite o dele (veja [App](/trilha/pt/referencia/app)). ### Um app dentro de um binário que já existe O arquivo gerado adota o pacote que a pasta declara, então um app Trilha pode ser um pacote comum, importável, dentro de um servidor `net/http` que você já roda: ```go // internal/crm/crm.go — package crm, escrito à mão // internal/crm/app/… — as rotas // internal/crm/trilha_gen.go — package crm, func NewApp() *trilha.App mux.Handle("/", crm.NewApp().Handler()) ``` A precedência, do mais explícito ao menos: `--package <nome>`; o pacote declarado pelos `.go` escritos à mão da pasta; o pacote declarado por um `trilha_gen.go` que já esteja lá; `main`. O terceiro passo é o que faz a bandeira valer uma vez só — o arquivo gerado lembra a escolha, e o `trilha gen --check` do CI não precisa dela. Fora do `package main` o construtor é exportado (`NewApp`, porque quem chama mora em outro pacote) e nenhum `func main()` é escrito. O `trilha dev` e o `trilha build` recusam um app assim e dizem quem o roda: não há binário aqui, o hospedeiro é que tem um. ## Códigos de saída `0` sucesso; `1` erro de geração, compilação ou execução; `2` uso incorreto. --- # Desempenho e comparação Source: /trilha/pt/referencia/desempenho Quanto o Trilha custa sobre a biblioteca padrão, como medir você mesmo, e como ele se posiciona frente a outras abordagens. ## Metodologia O único número que faz sentido publicar é o **custo do framework sobre a biblioteca padrão**, que é a alternativa real em Go. Os benchmarks ficam em `bench/` (módulo separado, para o Trilha continuar sem dependências) e medem, em processo (`httptest`, sem rede), o mesmo trabalho feito de dois jeitos: com o Trilha e com `net/http` + `html/template` puros. ```bash git clone https://github.com/emersonjoe/trilha && cd trilha make bench # roda; make bench-results regrava bench/RESULTS.md ``` Cenários: página com layout e 20 itens (`h` × `html/template`), resposta JSON, arquivo estático (`Public` × `http.FileServer`), 200 rotas com parâmetro (`ServeMux` nos dois lados) e cadeia de 5 middlewares. ## Resultados de referência Apple M2, Go 1.25, 2026-09-05 (mediana de 3 execuções; `bench/RESULTS.md` tem a saída completa). Valores por requisição. | Cenário | Stdlib | Trilha | Diferença | |---|---|---|---| | Página (20 itens, layout) | 29,4 µs · 270 allocs | 19,4 µs · 482 allocs | `h` é ~34 % mais rápido que `html/template` aqui, com mais alocações | | JSON (20 itens) | 4,2 µs | 7,6 µs | +3,4 µs | | Estático (1,4 KB) | 1,4 µs | 4,3 µs | +2,9 µs | | 200 rotas + parâmetro | 0,72 µs | 4,0 µs | +3,3 µs | | 5 middlewares | 0,64 µs | 4,1 µs | +3,4 µs | Leitura honesta: o Trilha tem um **custo fixo de ~3 µs e ~40 alocações por requisição**, independente da rota. Ele paga por: id de requisição (aleatório), nonce da CSP, cabeçalhos de segurança, `Ctx` com mapa de valores, limite de corpo, medição e **log estruturado** de cada requisição (`slog`, que formata a linha mesmo descartada). Em um servidor real uma consulta ao banco custa de 100 µs a alguns ms, e a rede, mais; a diferença desaparece. Se um dia isso importar para você, o caminho é reduzir alocações no `Ctx` e tornar o log opcional por rota — e o benchmark está aí para provar o ganho. ### Observabilidade | Cenário | Sem métricas | Com métricas | Diferença | |---|---|---|---| | Rota trivial (`c.Text`) | 4,1 µs · 50 allocs | 4,1 µs · 50 allocs | dentro do ruído; **zero alocações** | | Sonda `/_trilha/health/live` | — | 0,9 µs · 18 allocs | não passa pelo roteador nem pela cadeia de middleware | A instrumentação só existe quando `Observability.Metrics` está configurado; desligada, é uma comparação de ponteiro. Ligada, a chave da série é montada num buffer de pilha e procurada como `map[string(bytes)]`, forma que o compilador resolve sem alocar — por isso a contagem de alocações não muda. O ciclo **editar → ver** do `trilha dev` fica em ~1,2 s no exemplo do blog (recompilação do Go) e ~30 ms para mudanças só em `public/` (`make reload` mede na sua máquina). ## Comparação de abordagem Sem números de terceiros: versões mudam, configurações diferem e cada projeto otimiza para coisas diferentes. O que dá para comparar com segurança é a **abordagem**. Confira sempre a documentação de cada um; nomes citados são marcas dos respectivos donos e não há afiliação. | | Trilha | `net/http` puro | Roteadores Go (chi, echo, gin, fiber) | templ + htmx | Next.js | |---|---|---|---|---|---| | Rotas | por pastas em `app/` (`page.go`, `route.go`) | registradas à mão | registradas à mão | registradas à mão (com o roteador que você escolher) | por pastas em `app/` | | Layouts aninhados | `layout.go` por pasta | manual | manual | componentes | `layout.tsx` | | HTML | DSL tipado `h` (escape por padrão) ou `html/template` | `html/template` | `html/template` ou libs | `templ` (compilado) | JSX/React | | Interatividade no cliente | HTML + `ui.js` (200 linhas) ou htmx; sem hidratação | você escolhe | você escolhe | htmx | React (hidratação, RSC) | | Dependências no runtime | nenhuma | nenhuma | o roteador (+ deps) | `templ` (+ gerador) | Node, React, Next | | Dev | `trilha dev`: recarga ~1 s, erro de compilação na página | `go run` manual | `air`/manual | `templ generate --watch` + reload | `next dev` (HMR) | | Produção | um binário estático com `public/` embutido | binário | binário | binário | Node ou edge; build | | Export estático | `trilha export` | manual | manual | manual | `output: 'export'` | | Segurança padrão | CSP com nonce, HSTS, CSRF, rate limit, cookies assinados, timeouts | nada (você configura) | varia | nada (você configura) | cabeçalhos básicos; CSRF em Server Actions | | IA | `ai` (OpenAI-compatível), `ai/mcp` | — | — | — | Vercel AI SDK (pacote) | Quando **não** usar o Trilha: apps que precisam de interface altamente interativa no cliente (editores, dashboards em tempo real com estado complexo) são melhor servidos por React/Next ou por um SPA; e projetos que já têm um roteador Go e templates maduros ganham pouco ao trocar. O Trilha brilha em apps de negócio renderizados no servidor, sites de conteúdo e APIs com painel, onde um binário sem dependências e convenções fortes pesam mais que interatividade fina. ## Custo por feature para um agente Os números acima são o que o framework custa por requisição. Há um segundo custo, pago por quem escreve o app com uma ferramenta de IA: os tokens que um agente gasta descobrindo o que o projeto já tem, errando uma assinatura, rodando cinco verificações uma de cada vez. É isso que o `bench/agent` mede. `make bench-agent` copia `examples/blog` ou `examples/sso` para um módulo próprio, roda um agente de código (`claude -p`, sem servidores MCP, plugins ou memória do usuário: só o que está dentro do projeto conta) em quatro tarefas fixas e decide passou ou não com um teste escondido: | Cenário | Tarefa | |---|---| | `comments` | `POST`/`GET /api/posts/{id}/comments` com `Bind`, validação, 404 | | `contact-form` | página `/contato` dentro do layout raiz com formulário do kit `ui` | | `cognito` | trocar o provedor de login do exemplo SSO de Keycloak para Cognito | | `pagination` | cinco posts por página em `/blog`, com `?page=N` e anterior/próxima | Cada cenário roda três vezes; `bench/agent/RESULTS.md` mostra a mediana de tokens de entrada (novos e lidos do cache), de saída, rodadas, chamadas recusadas, tempo e custo, e quantas execuções passaram. A comparação é sempre Trilha antes contra Trilha depois — mesma tarefa, mesmo agente, mesmo modelo — nunca contra outro framework. `make bench-agent-dry` monta as fixtures e prova que os testes escondidos falham sem agente, sem gastar nada; o CI nunca roda o agente. --- # Receitas Source: /trilha/pt/receitas As partes que todo app precisa e nenhum framework decide por você — banco, sessão, upload, paginação, e-mail, tarefa agendada, Docker — com código que compila. "Aprender" ensina o framework e "Referência" descreve cada símbolo. Esta seção responde a um terceiro tipo de pergunta, a que aparece no segundo dia: *como eu faço a coisa que todo app faz?* Abrir um banco, manter alguém logado, receber um arquivo, paginar uma lista, mandar um e-mail, rodar uma tarefa de hora em hora, colocar tudo isso num contêiner. Nada disso é decisão do framework. Trilha não tem ORM, não tem store de sessão e não tem mailer — o que ele tem é um lugar para os seus, e é aqui que esse lugar está escrito. | Receita | O que responde | |---|---| | [Banco de dados](/trilha/pt/receitas/banco-de-dados) | pool, consultas, transação, migrações, sqlc | | [Sessões](/trilha/pt/receitas/sessoes) | login, cookie assinado, usuário atual, flash | | [Uploads](/trilha/pt/receitas/uploads) | receber um arquivo, validar, guardar, devolver | | [Paginação](/trilha/pt/receitas/paginacao) | página e cursor, e o rodapé que vem com eles | | [E-mail](/trilha/pt/receitas/email) | SMTP em produção, o log em dev, um corpo vindo de template | | [Tarefas agendadas](/trilha/pt/receitas/tarefas-agendadas) | um ticker que sobe com o app e para com ele | | [Docker](/trilha/pt/receitas/docker) | uma imagem pequena, as variáveis, a sonda de saúde | | [Checklist de produção](/trilha/pt/receitas/checklist-de-producao) | o que conferir antes de publicar, em ordem | | [Migração](/trilha/pt/receitas/migracao) | de `net/http` puro para Trilha, e entre versões menores | ## De onde vem o código Todo bloco Go destas páginas é copiado de um arquivo em [`examples/cookbook`](https://github.com/emersonjoe/trilha/tree/main/examples/cookbook), que faz parte do módulo do repositório: `go vet ./...` compila esse código a cada rodada, e um teste do site confere que cada bloco continua aparecendo, caractere por caractere, no arquivo de onde saiu. Uma receita que para de compilar quebra o build antes de enganar alguém. Isso tem um preço que vale conhecer: o pacote usa só a biblioteca padrão, como o resto do repositório. Então não há driver de banco, nem hash de senha, nem cliente de métricas dentro dele. Onde um deles é necessário, a página diz qual linha acrescentar e por que ela não está aqui. :::nota As receitas assumem as convenções de [Páginas e rotas](/trilha/pt/aprender/paginas-e-rotas) e o `app/setup.go` de [App](/trilha/pt/referencia/app). Se um trecho fala em `Setup`, ele mora nesse arquivo; se fala em `Config`, ele roda antes de o app existir. ::: --- # Banco de dados Source: /trilha/pt/receitas/banco-de-dados Um pool para o processo, consultas que carregam o contexto da requisição, uma transação que desfaz sozinha e migrações aplicadas em ordem. Trilha não abre o seu banco. O que ele dá são os dois momentos que importam: o `Setup`, que roda uma vez antes de o servidor subir, e o contexto da requisição, que é o que faz uma consulta parar quando o visitante desiste. ## O pool `database/sql` já é um pool. Um por processo — um pool por pacote é um teto de conexões que ninguém somou, e um pool por requisição é uma tempestade de conexões no primeiro minuto movimentado. ```go // OpenDB opens the pool and proves it works. sql.Open does not connect, so // a wrong password only shows up on the first query — usually a visitor's. // The ping moves that failure to the start of the process, where a deploy // can still be rolled back. func OpenDB(driver, dsn string) (*sql.DB, error) { db, err := sql.Open(driver, dsn) if err != nil { return nil, err } // The database has a connection limit and it is smaller than you think. // Max open is what one instance may hold; idle equal to it keeps the // pool from opening and closing a connection per burst. db.SetMaxOpenConns(20) db.SetMaxIdleConns(20) db.SetConnMaxLifetime(30 * time.Minute) db.SetConnMaxIdleTime(5 * time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := db.PingContext(ctx); err != nil { db.Close() return nil, fmt.Errorf("%s: %w", driver, err) } return db, nil } ``` O `import` que faz de `"pgx"` um nome de verdade é a única linha que este arquivo não pode ter, porque o repositório não tem dependência externa: ```text import _ "github.com/jackc/pgx/v5/stdlib" // driver "pgx" import _ "modernc.org/sqlite" // driver "sqlite", sem cgo ``` Para SQLite há mais uma coisa a dizer, e ela não é opcional: `_pragma=journal_mode(WAL)` no DSN, mais `db.SetMaxOpenConns(1)` para escrita. Sem WAL, a segunda escrita concorrente recebe `database is locked` — e isso vai acontecer em produção, não nos seus testes. ## Onde ele é aberto ```go // SetupDB is what app/setup.go does with the pool: open it, hand it to the // packages that query, tell the health probe about it, and close it on the // way out. func SetupDB(a *trilha.App) error { db, err := OpenDB("pgx", os.Getenv("DATABASE_URL")) if err != nil { return err } DB = db a.Check("db", func(ctx context.Context) error { return db.PingContext(ctx) }) a.OnShutdown(func(*trilha.App) error { return db.Close() }) return nil } ``` Três coisas em seis linhas, e as duas últimas são as que se esquecem. `a.Check` coloca o pool dentro de `/_trilha/health/ready`, então uma instância que perdeu o banco para de receber tráfego em vez de responder 500 para todo mundo. `a.OnShutdown` fecha o pool depois da última requisição, não durante ela. ## Lendo ```go // ArticleBySlug reads one row. sql.ErrNoRows is not a failure of the // server: it is the page not existing, and a handler that lets it through // answers 500 to something that deserved a 404. func ArticleBySlug(ctx context.Context, slug string) (Article, error) { var a Article err := DB.QueryRowContext(ctx, `SELECT id, slug, title, published_at FROM articles WHERE slug = $1`, slug). Scan(&a.ID, &a.Slug, &a.Title, &a.Published) switch { case errors.Is(err, sql.ErrNoRows): return Article{}, trilha.ErrNotFound case err != nil: return Article{}, fmt.Errorf("article %q: %w", slug, err) } return a, nil } ``` `sql.ErrNoRows` é o bug mais comum deste arquivo. Ele não é uma falha do servidor: é a página não existir. Devolver `trilha.ErrNotFound` transforma isso no 404 que o visitante merece — e, numa rota `/api`, num corpo `problem+json` com o status certo. Uma lista é a mesma coisa, com as linhas fechadas por um `defer` e o `rows.Err()` conferido no fim, porque uma conexão que caiu no meio se parece exatamente com o fim da lista: ```go // Articles reads a list. The context is the request's: when the visitor // gives up, the query is cancelled instead of holding a connection for a // page nobody will read. func Articles(ctx context.Context, limit int) ([]Article, error) { rows, err := DB.QueryContext(ctx, `SELECT id, slug, title, published_at FROM articles ORDER BY published_at DESC LIMIT $1`, limit) if err != nil { return nil, err } defer rows.Close() var out []Article for rows.Next() { var a Article if err := rows.Scan(&a.ID, &a.Slug, &a.Title, &a.Published); err != nil { return nil, err } out = append(out, a) } return out, rows.Err() } ``` :::atencao O contexto vem de `c.Context()`, sempre. Uma consulta iniciada com `context.Background()` dentro de um handler continua rodando depois que o visitante fecha a aba, segurando uma conexão por uma resposta que ninguém vai ler. ::: ## Escrevendo, e desfazendo ```go // InTx runs fn inside a transaction. The rollback is deferred without a // condition because rolling back a committed transaction does nothing: that // is what keeps a panic in the middle from leaving the transaction open. func InTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if err := fn(tx); err != nil { return err } return tx.Commit() } ``` O `defer tx.Rollback()` sem condição é o ponto: desfazer uma transação que já foi confirmada não faz nada, então a chamada adiada é de graça no caminho feliz e é a única coisa que fecha a transação quando o código do meio entra em pânico. ## Migrações Uma ferramenta de migração é uma escolha boa. Ela também é uma dependência, um binário na imagem e um passo no deploy — e a coisa toda são trinta linhas com `embed`: ```go // Migrate applies every file in migrations/ the database has not seen, in // name order, each one with its record in the same transaction. Either the // migration and its receipt land together or neither does. func Migrate(ctx context.Context, db *sql.DB) error { if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL)`); err != nil { return err } names, err := fs.Glob(migrations, "migrations/*.sql") if err != nil { return err } sort.Strings(names) for _, name := range names { var applied int if err := db.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations WHERE name = $1`, name).Scan(&applied); err != nil { return err } if applied > 0 { continue } body, err := migrations.ReadFile(name) if err != nil { return err } err = InTx(ctx, db, func(tx *sql.Tx) error { if _, err := tx.ExecContext(ctx, string(body)); err != nil { return fmt.Errorf("%s: %w", name, err) } _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (name, applied_at) VALUES ($1, $2)`, name, time.Now().UTC()) return err }) if err != nil { return err } } return nil } ``` O arquivo e o recibo dele caem na mesma transação: ou os dois, ou nenhum. Chame isso do `Setup` antes de o servidor escutar, ou de um comando separado se o seu deploy aplica as migrações antes de subir a versão nova — que é o formato melhor a partir da segunda instância. ## sqlc Tudo acima escreve o `Scan` à mão. O [sqlc](https://sqlc.dev) gera esse código a partir do SQL que você já escreveu: entra um arquivo `.sql`, sai um método tipado, e uma coluna que muda de nome vira erro de compilação. ```yaml version: "2" sql: - engine: postgresql queries: internal/db/query.sql schema: internal/db/migrations gen: go: package: db out: internal/db ``` Ele encaixa no framework sem adaptador nenhum, porque o que sai é Go comum: o `*db.Queries` gerado vai no `Setup` exatamente onde o `DB` vai acima. A troca é o gerador no ciclo — mais um comando para rodar quando o SQL muda, e mais uma coisa para explicar a quem chega. :::nota O sqlc roda em tempo de build, então ele não é dependência de execução do seu app. É essa distinção que faz dele uma decisão diferente de acrescentar um ORM. ::: --- # Sessões Source: /trilha/pt/receitas/sessoes Login com cookie assinado, o usuário atual num middleware, uma mensagem flash que sobrevive a um redirecionamento — e nada guardado no servidor. Uma sessão são duas decisões: o que prova quem você é, e onde essa prova fica. Trilha responde a primeira — um cookie assinado com o segredo do app, que o navegador não consegue forjar — e deixa a segunda com você. Esta receita não guarda nada no servidor: o cookie carrega o id do usuário, e toda requisição lê o usuário do banco. Isso custa uma consulta indexada por requisição e compra algo que vale mais: desativar uma conta passa a valer agora, não quando o cookie expirar. ## Entrando ```go // Login answers the form. The session is written before the redirect, // because a Set-Cookie on a 303 still reaches the browser. func Login(c *trilha.Ctx) error { u, err := Authenticate(c.Context(), c.Form("email"), c.Form("password")) if err != nil { return trilha.FieldErrors{"email": "wrong e-mail or password"} } if err := c.SetSigned(SessionCookie, strconv.FormatInt(u.ID, 10), SessionTTL); err != nil { return err } return c.Redirect(safeNext(c.Form("next"))) } ``` `SetSigned` escreve o cookie com `HttpOnly`, `SameSite=Lax` e `Secure` fora de dev, e assina o valor com o `Secret` do app. O valor não é criptografado e não precisa ser: ele é o id do próprio visitante. ```go // SessionCookie carries the user id, signed by the app's secret. What is // inside it is not secret — it is the id, and anyone may read their own — // but it cannot be changed without the key. const SessionCookie = "session" ``` A conferência da senha é a única coisa que o framework não vai fazer por você — e a biblioteca padrão também não: ```go // CheckPassword compares a password with the stored hash. The standard // library has no password hash worth using, so this is where your app plugs // in bcrypt or argon2; the default refuses everyone, which is the safe way // to notice it was never wired. var CheckPassword = func(hash, password string) bool { return false } ``` No seu app, essa variável aponta para `bcrypt.CompareHashAndPassword` ou `argon2.IDKey`. Aqui ela recusa todo mundo, para que esquecer de ligá-la falhe fechado. ```go // Authenticate reads the user and checks the password. One error for "no // such e-mail" and for "wrong password": telling them apart hands an // attacker a list of who has an account. func Authenticate(ctx context.Context, email, password string) (User, error) { var u User err := DB.QueryRowContext(ctx, `SELECT id, email, password_hash FROM users WHERE email = $1`, email). Scan(&u.ID, &u.Email, &u.Hash) if err != nil { u.Hash = dummyHash } if !CheckPassword(u.Hash, password) || err != nil { return User{}, ErrBadCredentials } return u, nil } ``` Dois detalhes pagam as suas linhas. O erro único faz com que a página de login não sirva para descobrir quais e-mails têm conta. O `dummyHash` faz com que ela também não sirva por tempo: ```go // dummyHash keeps the comparison cost the same for an e-mail that does not // exist: without it, the time the answer takes says which e-mails are real. const dummyHash = "$argon2id$v=19$m=65536,t=3,p=2$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ``` E o redirecionamento depois do login passa por uma checagem, porque `?next=` é o open redirect clássico — uma página de login que manda a pessoa para outro site depois de ela digitar a senha: ```go // safeNext refuses a destination that leaves the site: ?next= is how an // open redirect gets into a login page. func safeNext(next string) string { u, err := url.Parse(next) if err != nil || u.Scheme != "" || u.Host != "" || !strings.HasPrefix(u.Path, "/") || strings.HasPrefix(u.Path, "//") { return "/" } return u.Path } ``` ## O usuário atual O middleware roda para toda rota da pasta em que ele mora e abaixo dela, então `app/middleware.go` cobre o app inteiro: ```go // WithUser reads the session and puts the user in the request. It refuses // nobody: a page that requires a login says so itself, and a page that only // greets by name works either way. func WithUser(c *trilha.Ctx, next trilha.Next) error { if id, ok := c.Signed(SessionCookie); ok { if u, err := UserByID(c.Context(), id); err == nil { c.Set(UserKey, u) } } return next() } ``` Ele não recusa ninguém, de propósito. Uma página que exige login diz isso por conta própria, e uma página que só cumprimenta pelo nome funciona dos dois jeitos: ```go // RequireUser sends anyone the middleware did not recognise to the login // page, remembering where they were going. func RequireUser(c *trilha.Ctx, next trilha.Next) error { if _, ok := c.Get(UserKey).(User); !ok { return c.Redirect("/login?next=" + url.QueryEscape(c.Request().URL.Path)) } return next() } ``` ```go // CurrentUser is what a handler calls. The zero User means nobody is // logged in, so a page can ask without checking twice. func CurrentUser(c *trilha.Ctx) User { u, _ := c.Get(UserKey).(User) return u } ``` O `User` zerado quer dizer "ninguém", então uma página pode perguntar sem conferir duas vezes. Ler o usuário é uma consulta, e é a consulta que dá dentes à sessão: ```go // UserByID reads the user the session points at, on every request. That is // one indexed query for the ability to disable an account and have it take // effect now, instead of when the cookie expires. func UserByID(ctx context.Context, id string) (User, error) { var u User err := DB.QueryRowContext(ctx, `SELECT id, email, password_hash FROM users WHERE id = $1 AND active`, id). Scan(&u.ID, &u.Email, &u.Hash) return u, err } ``` :::nota Quer a sessão num store? Troque o `UserByID` para ler o seu store e mantenha todo o resto. O cookie continua carregando um id opaco; o que muda é onde esse id é procurado. ::: ## Saindo ```go // Logout clears the cookie. Nothing is stored on the server, so there is // nothing else to forget. func Logout(c *trilha.Ctx) error { c.ClearCookie(SessionCookie) return c.Redirect("/") } ``` Não há mais nada para esquecer, o que é a vantagem de uma sessão sem estado — e o limite dela: um cookie roubado vale até expirar. Se você precisa revogar um, precisa do store. ## Flash A mensagem que tem que sobreviver a um redirecionamento e depois sumir: ```go // Flash writes the message the next page will show. func Flash(c *trilha.Ctx, msg string) error { return c.SetSigned(FlashCookie, msg, 5*time.Minute) } ``` ```go // TakeFlash reads the message and clears it, so a reload does not show it // again. func TakeFlash(c *trilha.Ctx) string { msg, ok := c.Signed(FlashCookie) if !ok { return "" } c.ClearCookie(FlashCookie) return msg } ``` Assinada, para que ninguém coloque um texto próprio na sua página editando um cookie. Lida uma vez, para que um recarregamento não a mostre de novo. :::dica Testar isso não precisa de cliente HTTP: `trilha.WithSigned("session", "42")` escreve uma sessão válida numa requisição de teste, e `res.Cookie("session")` é como você prova que o logout limpou o cookie. Veja [Testes](/trilha/pt/aprender/testes). ::: --- # Uploads Source: /trilha/pt/receitas/uploads Receber um arquivo com teto, conferir o que ele é de verdade, guardar fora da árvore servida e devolver sem deixar rodar. Um upload é o caminho mais curto entre um formulário e um incidente de segurança: um corpo sem limite, um tipo tirado do nome do arquivo, um caminho que sai do diretório e um arquivo HTML devolvido a partir da sua própria origem. `c.File` fecha os três primeiros; o quarto é uma decisão sobre como você serve. ## Recebendo ```go // SaveAvatar takes the file from the form. c.File checks the size, sniffs // the real type instead of believing the name, and drops a filename that // tries to walk out of the directory. func SaveAvatar(c *trilha.Ctx) error { // The body limit is the file plus the rest of the form; without it, a // multipart request with no end is a slow way to fill the disk. c.AllowBody(MaxAvatar + 64<<10) up, err := c.File("avatar", trilha.FileRules{ MaxSize: MaxAvatar, Accept: []string{"image/png", "image/jpeg", "image/webp"}, }) if err != nil { return err } defer up.Close() name, err := up.Save(UploadDir) if err != nil { return err } if err := SetAvatar(c.Context(), CurrentUser(c).ID, name); err != nil { return err } if err := Flash(c, "Photo updated."); err != nil { return err } return c.Redirect("/account") } ``` `c.File` faz quatro coisas antes de o seu código ver o arquivo: | Checagem | O que evita | |---|---| | `MaxSize` | um arquivo maior que o teto, recusado como erro de campo | | `Accept` | um tipo fora da lista, farejado no conteúdo, não no nome | | o nome | `../../etc/passwd` e parecidos: o `Save` escreve um nome inventado por ele | | `Optional` | separar "nenhum arquivo" de "um arquivo quebrado" | O farejamento importa mais do que parece. O navegador manda o `Content-Type` que quiser e um script manda o que bem entender; a única coisa que diz o que um arquivo é, é o arquivo. ```go // MaxAvatar is the ceiling for one file. A limit that lives in a constant // is a limit somebody can find; a limit spread over three handlers is not. const MaxAvatar = 2 << 20 // 2 MiB ``` `c.AllowBody` é a outra metade do limite. O `MaxSize` recusa um arquivo grande demais depois de lê-lo; o limite de corpo impede a requisição de chegar até lá — um upload multipart sem fim é um jeito lento de encher um disco. O nome que vai para o banco é o que o `Save` devolveu, nunca o que o navegador mandou: ```go // SetAvatar records the name on disk, not the name the browser sent. func SetAvatar(ctx context.Context, user int64, file string) error { _, err := DB.ExecContext(ctx, `UPDATE users SET avatar = $1 WHERE id = $2`, file, user) return err } ``` ## Devolvendo Servir conteúdo de usuário da mesma origem do seu app é como um XSS armazenado consegue um cookie de sessão. O mount mais três cabeçalhos são a resposta inteira: ```go // ServeUploads is what Config does to hand the files back. os.DirFS answers // only what is under the directory, and the mount is a URL prefix: nothing // else on disk becomes reachable by adding ../ to an address. func ServeUploads(cfg *trilha.Config) { cfg.Mounts = map[string]fs.FS{"/uploads/": os.DirFS(UploadDir)} cfg.StaticHeaders = func(path string, hdr http.Header) { if !strings.HasPrefix(path, "/uploads/") { return } // Content someone else uploaded is never rendered as if it were // ours: no sniffing, and the browser downloads instead of running. hdr.Set("X-Content-Type-Options", "nosniff") hdr.Set("Content-Disposition", "attachment") hdr.Set("Content-Security-Policy", "sandbox; default-src 'none'") } } ``` `os.DirFS` só responde pelo que está sob o diretório, então `..` numa URL não alcança nada. Os cabeçalhos dizem o resto: não adivinhe o tipo, não renderize, baixe. :::atencao A versão forte disso é outro host — `uploads.exemplo.com`, ou um bucket com domínio próprio. Conteúdo na mesma origem é seguro só até onde os cabeçalhos que você lembrou alcançam; outra origem é segura porque o navegador não deixa ela encostar no seu site. ::: ## Onde os arquivos moram `UploadDir` é um diretório fora de `public/`, e fora da árvore do binário: ```go // UploadDir is where saved files land — a directory outside the tree the // binary serves, so a file can never be reached by guessing its path. var UploadDir = "var/uploads" ``` Numa máquina só, isso é um volume. Em mais de uma, tem que ser armazenamento compartilhado ou armazenamento de objetos, porque a instância que recebeu o arquivo não é a que vai ser perguntada por ele. É esse o momento em que o `Save` vira um cliente de S3 — o handler acima não muda, só muda para onde o `Save` escreve. :::dica A barra de progresso e a área de arrastar-e-soltar já estão no kit: `ui.UploadBar`, `ui.UploadTo` e `ui.UploadScript`, na [referência de ui](/trilha/pt/referencia/ui). ::: --- # Paginação Source: /trilha/pt/receitas/paginacao Paginar por offset enquanto a lista é curta, por cursor quando não é mais, uma linha a mais em vez de um COUNT, e links que um robô consegue seguir. Uma lista que cresce é paginada duas vezes: primeiro com `LIMIT`/`OFFSET`, porque é o óbvio, e depois com cursor, quando alguém percebe que a página 900 demora quatro segundos. As duas estão aqui, e a primeira serve para a maioria das listas. ## A janela ```go // WindowFrom reads ?page= and refuses what it cannot serve. The ceiling is // not paranoia: OFFSET 900000 makes the database walk every row it skips, // and a crawler will ask. func WindowFrom(c *trilha.Ctx) Window { n, err := strconv.Atoi(c.Query("page")) if err != nil || n < 1 { n = 1 } if n > 500 { n = 500 } return Window{Page: n, Size: PageSize} } ``` O teto não é paranoia. `OFFSET 18000` faz o banco ler e descartar dezoito mil linhas, e algum robô vai pedir a página 900 de tudo que você publica. ```go // Window is a page of a list: what was asked for, and whether there is more. type Window struct { Page, Size int HasNext bool } ``` ## Uma linha a mais ```go // ArticlesPage reads one page by offset. It asks for one row more than it // shows: that extra row is how you know there is a next page without a // second query counting the whole table. func ArticlesPage(ctx context.Context, w Window) ([]Article, Window, error) { rows, err := DB.QueryContext(ctx, `SELECT id, slug, title, published_at FROM articles ORDER BY published_at DESC, id DESC LIMIT $1 OFFSET $2`, w.Size+1, (w.Page-1)*w.Size) if err != nil { return nil, w, err } defer rows.Close() var out []Article for rows.Next() { var a Article if err := rows.Scan(&a.ID, &a.Slug, &a.Title, &a.Published); err != nil { return nil, w, err } out = append(out, a) } if err := rows.Err(); err != nil { return nil, w, err } if len(out) > w.Size { out, w.HasNext = out[:w.Size], true } return out, w, nil } ``` Pedir `Size+1` e mostrar `Size` responde "existe página seguinte?" sem uma segunda consulta contando a tabela inteira. `COUNT(*)` numa tabela grande é a consulta que aparece no log de lentidão dois meses depois — e o total quase nunca é o que o rodapé precisa. A ordenação tem duas colunas por um motivo: `published_at` sozinho não é único, e um empate partido na fronteira de uma página mostra a mesma linha duas vezes ou pula uma. ## O cursor ```go // ArticlesAfter reads the next rows by cursor. The database jumps straight // to the position with the index, so page one thousand costs the same as // page one — and a row inserted meanwhile does not shift the whole list. func ArticlesAfter(ctx context.Context, cursor string, size int) ([]Article, string, error) { at, id := time.Now().Add(100*365*24*time.Hour), int64(0) if cursor != "" { var err error if at, id, err = parseCursor(cursor); err != nil { return nil, "", err } } rows, err := DB.QueryContext(ctx, `SELECT id, slug, title, published_at FROM articles WHERE (published_at, id) < ($1, $2) ORDER BY published_at DESC, id DESC LIMIT $3`, at, id, size) if err != nil { return nil, "", err } defer rows.Close() var out []Article for rows.Next() { var a Article if err := rows.Scan(&a.ID, &a.Slug, &a.Title, &a.Published); err != nil { return nil, "", err } out = append(out, a) } if err := rows.Err(); err != nil { return nil, "", err } if len(out) < size { return out, "", nil // the end: no cursor to hand back } return out, Cursor(out[len(out)-1]), nil } ``` O `WHERE (published_at, id) < ($1, $2)` é o que torna isso barato: com o índice nessas duas colunas, o banco salta para a posição em vez de contar até ela, então a página mil custa o que a página um custa. Ele também conserta o bug que a paginação por offset tem por construção — uma linha inserida enquanto alguém lê desloca todas as páginas seguintes. ```go // Cursor packs the sort key of the last row. Base64 so it survives a URL, // not because it is a secret: whoever edits it sees another page of the // same public list and nothing else. func Cursor(a Article) string { raw := a.Published.UTC().Format(time.RFC3339Nano) + "|" + strconv.FormatInt(a.ID, 10) return base64.RawURLEncoding.EncodeToString([]byte(raw)) } ``` Base64 porque ele viaja numa URL, não porque esconde alguma coisa: quem editar vê outra página da mesma lista pública. ## O rodapé ```go // Pages renders the footer of a list. They are links, not buttons: a page // number belongs in the address, so it can be shared, reloaded and read by // whoever indexes the site. func Pages(path string, w Window) h.Node { href := func(n int) string { return path + "?page=" + strconv.Itoa(n) } return h.Nav(h.Class("paginas"), h.Aria("label", "Pagination"), h.If(w.Page > 1, h.A(h.Rel("prev"), h.Href(href(w.Page-1)), h.Text("Previous"))), h.Span(h.Textf("Page %d", w.Page)), h.If(w.HasNext, h.A(h.Rel("next"), h.Href(href(w.Page+1)), h.Text("Next"))), ) } ``` Links, não botões. O número da página pertence ao endereço, para poder ser compartilhado, recarregado e indexado; `rel="prev"`/`rel="next"` é o que um robô lê para entender a sequência. :::dica `c.Fragment()` transforma isso numa lista que cresce no lugar sem recarregar tudo: o handler responde só o `<ul>` quando a requisição é um fragmento. Veja [Interatividade](/trilha/pt/aprender/interatividade). ::: :::nota Qual usar: offset enquanto a lista é navegada por gente que pula para uma página, cursor para qualquer coisa que uma máquina percorre — uma API, uma exportação, uma rolagem infinita. Na API a resposta é sempre o cursor: número de página sobre uma lista que muda devolve duplicatas. ::: --- # E-mail Source: /trilha/pt/receitas/email Uma interface que os handlers chamam, SMTP atrás dela em produção, o log em dev, um corpo vindo de template e cabeçalhos que se recusam a ser injetados. Mandar e-mail são três problemas vestindo um casaco só: falar com um servidor, montar uma mensagem válida e não mandar nada a partir de um teste. Só o primeiro é sobre SMTP. ## A costura ```go // Mailer is what the handlers call. They never learn which one they got, // which is the whole point: the test and the dev server do not send mail. type Mailer interface { Send(ctx context.Context, to []string, subject, body string) error } ``` Uma interface com um método, definida onde é usada. Os handlers nunca ficam sabendo qual implementação receberam, e é esse o ponto inteiro: um teste que cadastra um usuário não pode mandar e-mail para um endereço de verdade. ```go // SetupMailer makes the choice once, at startup. Production without an // address configured fails to start, which is better than a sign-up that // silently sends nothing. func SetupMailer(a *trilha.App) error { if a.Env() == trilha.Dev { trilha.Provide[Mailer](a, LogMailer{Log: a.Logger()}) return nil } addr, from := os.Getenv("SMTP_ADDR"), os.Getenv("SMTP_FROM") if addr == "" || from == "" { return errors.New("SMTP_ADDR and SMTP_FROM are required outside dev") } host, _, _ := strings.Cut(addr, ":") trilha.Provide[Mailer](a, SMTPMailer{ Addr: addr, From: from, Auth: smtp.PlainAuth("", os.Getenv("SMTP_USER"), os.Getenv("SMTP_PASSWORD"), host), }) return nil } ``` ```go // SendWelcome is the other end of the seam: a handler asks for the interface, // never for the implementation behind it. The type argument is what Provide // filed the value under, which is why it is written out here — LogMailer and // SMTPMailer are two answers to the same question. func SendWelcome(c *trilha.Ctx, name, email, link string) error { return Welcome(c.Context(), trilha.Use[Mailer](c), name, email, link) } ``` `Provide` guarda o mailer sob `Mailer`, a interface, e não sob a struct que por acaso está atrás dela hoje — é para isso que serve o argumento de tipo. Um handler que pede `Mailer` recebe o log em dev e o SMTP em produção, e nunca fica sabendo da diferença. Produção sem endereço configurado se recusa a subir. Isso é de propósito: um cadastro que silenciosamente não manda nada é descoberto por um cliente, e um processo que não sobe é descoberto pelo deploy. ## Enviando ```go // SMTPMailer sends through a real server. type SMTPMailer struct { Addr string // "smtp.example.com:587" From string Auth smtp.Auth } ``` ```go // Send hands the message to the server. smtp.SendMail takes no context, so // the deadline is honoured here: when the request gives up, the handler // returns and the goroutine finishes on its own. func (m SMTPMailer) Send(ctx context.Context, to []string, subject, body string) error { msg, err := Message(m.From, to, subject, body) if err != nil { return err } done := make(chan error, 1) go func() { done <- smtp.SendMail(m.Addr, m.Auth, m.From, to, msg) }() select { case err := <-done: return err case <-ctx.Done(): return ctx.Err() } } ``` `smtp.SendMail` não recebe contexto, e um servidor de e-mail que para de responder seguraria a requisição até o timeout de escrita. O `select` devolve o prazo ao handler; a goroutine termina sozinha. :::nota A porta 587 com `PlainAuth` quer dizer STARTTLS, e o `net/smtp` recusa autenticação em texto numa conexão que não está cifrada — essa recusa é um recurso. A porta 465 é TLS implícito, que o `net/smtp` não faz sozinho: conecte com `tls.Dial` e use `smtp.NewClient`. ::: ## A mensagem ```go // Message assembles the bytes of RFC 5322. A newline inside a header is how // a form field becomes a second Bcc:, so anything that came from outside is // refused rather than escaped. func Message(from string, to []string, subject, body string) ([]byte, error) { for _, v := range append([]string{from, subject}, to...) { if strings.ContainsAny(v, "\r\n") { return nil, errors.New("cookbook: header injection") } } var b strings.Builder fmt.Fprintf(&b, "From: %s\r\n", from) fmt.Fprintf(&b, "To: %s\r\n", strings.Join(to, ", ")) fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject)) b.WriteString("MIME-Version: 1.0\r\n") b.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n") b.WriteString(strings.ReplaceAll(body, "\n", "\r\n")) return []byte(b.String()), nil } ``` O laço lá em cima é a única checagem de segurança deste arquivo e a que costuma faltar. Uma quebra de linha dentro de um cabeçalho é como um campo "nome" de formulário vira um segundo `Bcc:` — seu servidor, a lista de outra pessoa. Recusar é o certo; escapar é um chute. O corpo vem de `text/template`, não de `html/template`: ```go // welcome is text/template, not html/template: what is being escaped here // is nothing, and HTML escaping in a plain-text mail turns an apostrophe // into '. var welcome = template.Must(template.New("welcome").Parse( `Hello, {{.Name}}. Your account is ready. Set your password here: {{.URL}} This link is good for one hour. `)) ``` ```go // Welcome renders the body and sends it. func Welcome(ctx context.Context, m Mailer, name, email, link string) error { var b strings.Builder if err := welcome.Execute(&b, struct{ Name, URL string }{name, link}); err != nil { return err } return m.Send(ctx, []string{email}, "Welcome", b.String()) } ``` Escape de HTML num e-mail de texto puro transforma um apóstrofo em `'` na caixa de entrada de alguém. Se você manda um e-mail multipart com HTML, aí o `html/template` é o certo para aquela parte — e a parte de texto vai junto, porque muito cliente mostra ela. ## Em dev ```go // LogMailer is the implementation for dev and tests: it writes the message // to the log. Nobody's inbox learns about your fixtures. type LogMailer struct{ Log *slog.Logger } ``` A mensagem inteira no log, link incluído, que é o que você de fato precisa quando está testando uma redefinição de senha pela quinta vez. :::dica Duas outras implementações se pagam: uma que junta as mensagens numa fatia, para os testes conferirem, e uma que escreve arquivos `.eml` num diretório para você abrir num cliente de e-mail. ::: --- # Tarefas agendadas Source: /trilha/pt/receitas/tarefas-agendadas Um ticker que sobe com o app e para com ele, um tique por vez, um pânico que não derruba o processo — e a linha em que o cron passa a ser a resposta certa. Algum trabalho não tem requisição por trás: expirar sessões, mandar um resumo, tentar de novo o que falhou. Enquanto há uma instância, uma goroutine com um ticker é a resposta inteira, e ela mora dentro do app, com o mesmo pool e o mesmo logger. ## O formato ```go // Job is something that runs on a schedule inside the process. It is the // right shape while one instance runs it; the moment there are two, the // answer is a queue or a lock in the database, not a second ticker. type Job struct { Name string Every time.Duration Run func(context.Context) error } ``` ```go // Start runs the job until the context is cancelled. A tick that arrives // while the previous run is still going is dropped, not queued: a job that // takes longer than its interval must not pile up copies of itself. func Start(ctx context.Context, log *slog.Logger, j Job) { t := time.NewTicker(j.Every) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: runJob(ctx, log, j) } } } ``` Um `time.Ticker` descarta um tique quando ninguém está recebendo, e é esse o comportamento que você quer: uma tarefa que demora mais que o intervalo não pode acumular cópias de si mesma. O laço chama a tarefa e só depois volta a esperar. ```go // runJob keeps one tick from taking the process down. A panic in a // background task is a bug, and a bug in one task should not stop the ones // that work. func runJob(ctx context.Context, log *slog.Logger, j Job) { defer func() { if r := recover(); r != nil { log.Error("job panicked", "job", j.Name, "panic", r) } }() start := time.Now() if err := j.Run(ctx); err != nil { log.Error("job failed", "job", j.Name, "error", err, "took", time.Since(start)) return } log.Info("job done", "job", j.Name, "took", time.Since(start)) } ``` O `recover` não é otimismo sobre o seu código. Um pânico numa goroutine derruba o processo inteiro — servidor HTTP incluído — então um bug solto no resumo noturno pararia o site. Registrado e pulado, ele custa uma execução. ## Subindo e parando ```go // SetupJobs starts the tasks and makes the shutdown wait for them. A job // killed halfway is a row written and an e-mail not sent, and it is the // hardest kind of bug to reproduce. func SetupJobs(a *trilha.App) error { ctx, stop := context.WithCancel(context.Background()) var wg sync.WaitGroup for _, j := range []Job{ {Name: "expire-sessions", Every: time.Hour, Run: expireSessions}, } { wg.Add(1) go func() { defer wg.Done() Start(ctx, a.Logger(), j) }() } a.OnShutdown(func(*trilha.App) error { stop() wg.Wait() return nil }) return nil } ``` O contexto é cancelado no desligamento e o `WaitGroup` faz o processo esperar o tique em andamento. Sem ele, um deploy mata uma tarefa no meio: a linha foi escrita, o e-mail não foi, e esse é o tipo de bug mais difícil de reproduzir. A tarefa em si é uma função comum que recebe um contexto, então um teste chama ela direto, sem temporizador nenhum no meio: ```go // expireSessions is an ordinary function taking a context: nothing about it // knows it runs on a timer, so a test calls it directly. func expireSessions(ctx context.Context) error { _, err := DB.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < now()`) return err } ``` ## Quando "uma instância" deixa de ser verdade No momento em que há duas instâncias, as duas ticam. Toda tarefa roda duas vezes, e "mandar o resumo" vira "mandar o resumo duas vezes". | Situação | O que fazer | |---|---| | uma instância | esta receita | | mais de uma, tarefa idempotente | mantenha; rodar duas vezes não muda nada | | mais de uma, tarefa que só pode rodar uma vez | um lock no banco | | trabalho que precisa sobreviver a um restart | uma fila, não um ticker | O lock é menor do que parece — uma linha por nome de tarefa, um update condicional que só dá certo para a instância que chegar primeiro: ```sql UPDATE job_locks SET locked_until = now() + interval '5 minutes', owner = $1 WHERE name = $2 AND locked_until < now(); ``` Se o update não tocou nenhuma linha, outra instância está rodando, e esta pula o tique. :::nota E não há nada de errado em um `cron` chamando uma rota com token, ou um timer do systemd rodando o seu binário com um subcomando. A vantagem é a separação: uma tarefa que trava não segura uma vaga no servidor. O custo é mais uma coisa para publicar. ::: --- # Docker Source: /trilha/pt/receitas/docker Um binário estático numa imagem distroless, os assets já dentro dele, as variáveis que ele precisa e uma sonda de saúde que o orquestrador consegue usar. Um app Trilha é um binário só, com as páginas compiladas dentro e, se você usou `//go:embed`, os arquivos estáticos também. Isso deixa a imagem pequena o bastante para que a parte interessante seja o que você tira dela. ## O Dockerfile ```dockerfile FROM golang:1.22 AS build WORKDIR /src # Dependências primeiro: esta camada fica em cache até o go.mod mudar. COPY go.mod go.sum ./ RUN go mod download COPY . . # O arquivo gerado é commitado, mas gerar de novo no build é como você # descobre que alguém esqueceu de rodar o trilha gen. RUN go run ./cmd/trilha gen RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app ./ FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /app /app # A porta é documentação; a plataforma decide o que publica. EXPOSE 3000 USER nonroot ENTRYPOINT ["/app"] ``` Duas linhas carregam o peso. `CGO_ENABLED=0` faz um binário estático, que é o que permite o segundo estágio ser `distroless/static` — sem shell, sem gerenciador de pacotes, nada para explorar que não seja o seu código. `nonroot` quer dizer que um bug no seu app é um bug rodando como uid 65532. :::atencao `CGO_ENABLED=0` e os drivers de SQLite que precisam de cgo são mutuamente exclusivos. Ou você usa um driver em Go puro (`modernc.org/sqlite`), ou constrói sobre `debian:bookworm-slim` e aceita a imagem maior. ::: ## O endereço `trilha.ConfigFromEnv` já lê `PORT` e `ADDR`, então a plataforma que entrega uma porta é obedecida sem código — o padrão é `:3000`, e `:3000` significa todas as interfaces, que é o que um contêiner precisa. A imagem quebrada mais comum é a que mandaram escutar em `127.0.0.1`: dentro do contêiner isso é o próprio contêiner, e nada de fora chega nele. ## As variáveis | Variável | O que é | |---|---| | `TRILHA_ENV` | `prod` — desliga o recarregamento de dev, a página de erro com fonte, o log verboso | | `TRILHA_SECRET` | a chave de cookies e CSRF; pelo menos 32 bytes, vinda do cofre da plataforma | | `PORT` ou `ADDR` | onde escutar; `:3000` por padrão | | `DATABASE_URL` | o DSN do seu pool | | `TRILHA_BASE_PATH` | só quando o app não está na raiz do domínio | Um segredo assado na imagem é um segredo no registry, e em todo cache de camada que já baixou ela. Rodar a chave é `TRILHA_SECRET_PREVIOUS` com o valor antigo por um deploy ou dois, para que sessões assinadas com a chave velha continuem valendo enquanto expiram. ## Compose ```yaml services: app: build: . environment: TRILHA_ENV: prod DATABASE_URL: postgres://app:app@db:5432/app?sslmode=disable env_file: [.env] # TRILHA_SECRET mora aqui, não neste arquivo ports: ["8080:3000"] depends_on: db: { condition: service_healthy } healthcheck: test: ["CMD", "/app", "-health"] interval: 10s db: image: postgres:16-alpine environment: { POSTGRES_PASSWORD: app, POSTGRES_USER: app, POSTGRES_DB: app } healthcheck: test: ["CMD-SHELL", "pg_isready -U app"] interval: 5s volumes: [pgdata:/var/lib/postgresql/data] volumes: pgdata: ``` Uma imagem distroless não tem `curl` nem shell, então a checagem de saúde não pode ser um comando de shell contra uma URL. Duas saídas: uma flag `-health` no seu próprio binário, que pede `/_trilha/health/ready` e sai com o status, ou a sonda do próprio orquestrador — que é o que o Kubernetes faz, e não precisa de nada dentro da imagem: ```yaml livenessProbe: httpGet: { path: /_trilha/health/live, port: 3000 } readinessProbe: httpGet: { path: /_trilha/health/ready, port: 3000 } periodSeconds: 5 ``` `live` diz que o processo está de pé; `ready` diz que ele consegue servir, e é o que fica vermelho quando o banco sumiu. Ligar os dois trocados é como um contêiner que perdeu o banco passa a ser reiniciado para sempre em vez de ser tirado do balanceador. :::nota Existe uma resposta menor que um contêiner. `trilha export` escreve um site estático quando o app não tem rota dinâmica, e `trilha build` escreve o binário se tudo que você precisa é copiar um arquivo para uma máquina e rodar sob o systemd. Nem tudo precisa de orquestrador. ::: --- # Checklist de produção Source: /trilha/pt/receitas/checklist-de-producao O que conferir antes de publicar, em ordem: o que o trilha audit acha por você, o que ele não enxerga e as duas coisas a preparar para o dia em que der errado. A lista abaixo é para ser lida de cima para baixo, uma vez, antes do primeiro deploy — e de novo quando alguma coisa mudar de forma. Metade dela é um comando; a outra metade é uma decisão que ninguém toma por você. ## Rode o comando primeiro ```bash trilha audit ``` Ele se recusa a ser formalidade: sai com código diferente de zero em qualquer coisa crítica, então o CI pode barrar nele. O que ele confere, na ordem dele: | Checagem | Por que está na lista | |---|---| | `TRILHA_SECRET` definido e longo o bastante | sem segredo, cookies e CSRF são assinados com uma chave que muda a cada restart | | proxies confiáveis declarados | sem eles o `ClientIP` é o que o visitante digitou, e o rate limit não protege ninguém | | hosts permitidos declarados | uma requisição com o `Host` de outra pessoa recebe um link absoluto — e o seu cookie — apontando para lá | | métricas não públicas, token longo o bastante | `/metrics` é o mapa do seu app: rotas, volumes, taxas de erro | | pelo menos um `a.Check` | sem nenhum, o `ready` diz sim com o banco fora do ar | | assets com `immutable` | o único cabeçalho de cache que é seguro e vale a pena, porque o `c.Asset` põe hash no nome | | segredo OIDC fora do código, callback fora de texto claro | os dois jeitos de um login ser roubado | | `trilha_gen.go` fresco, CLI e biblioteca na mesma versão | um arquivo gerado que discorda do `app/` serve as rotas da semana passada | | Go suportado, `.gitignore` cobrindo `.env`, `go vet`, `govulncheck` | a higiene comum, que só faz falta quando falha | Corrija tudo que for crítico. Um aviso é uma decisão: escreva por que, ou corrija. ## O que o comando não enxerga ### Configuração ```go // Config is the production side of app/setup.go. Everything here has a // default that works in dev and is wrong behind a proxy on the open // internet — which is exactly the list worth reviewing before a deploy. func Config(cfg *trilha.Config) error { // Who may say which Host: without this, a request with someone else's // Host is answered with your session cookie in it. cfg.AllowedHosts = strings.Split(os.Getenv("ALLOWED_HOSTS"), ",") // The proxy in front. Only these addresses may set X-Forwarded-For, so // ClientIP is the visitor and not whatever the visitor typed. cfg.TrustedProxies = []string{"10.0.0.0/8"} // A request that never finishes is a connection that never returns. cfg.Timeouts = trilha.Timeouts{ ReadHeader: 5 * time.Second, Read: 30 * time.Second, Write: 30 * time.Second, Idle: 60 * time.Second, Shutdown: 20 * time.Second, } // The ceiling on a body nobody asked for; a route that receives files // raises its own with c.AllowBody. cfg.MaxBodyBytes = 1 << 20 cfg.RateLimit = trilha.RateLimit{RPS: 20, Burst: 40} // Metrics are opt-in and never public. ConfigFromEnv already read // TRILHA_METRICS and TRILHA_OBS_TOKEN; what is left is who may scrape. cfg.Observability.Trusted = []string{"10.0.0.0/8"} // HSTS is a promise the browser remembers: turn it on when the // certificate is already working, not before. cfg.Security.HSTS = "max-age=31536000; includeSubDomains" return nil } ``` Os timeouts são o item que se pula. Uma requisição que nunca termina é uma conexão que nunca volta, e a falha parece "o site está lento" até parecer "o site está fora". ### Dados - **Backup, e uma restauração que você de fato executou.** Um backup que ninguém restaurou é um arquivo, não um backup. Cronometre a restauração: esse número é a sua pior indisponibilidade. - **Migrações aplicadas antes de a versão nova servir**, não pela instância que acabou de subir — com mais de uma instância, duas delas rodam a mesma migração ao mesmo tempo. - **Um rollback que funciona.** Uma migração que remove uma coluna faz a versão anterior não subir. Acrescente a coluna, publique, pare de usá-la, remova na versão seguinte. ### Requisições - **Limite de corpo** em `MaxBodyBytes`, elevado por rota com `c.AllowBody` só onde chega arquivo. - **Rate limit** no que custa dinheiro: login, redefinição de senha, qualquer coisa que mande e-mail ou chame um modelo. - **`AllowedHosts` e HSTS** juntos — o HSTS é uma promessa que o navegador guarda por um ano, então ligue depois que o certificado funciona, nunca antes. ### O que você vai olhar quando quebrar - **Logs estruturados indo para algum lugar em que dá para pesquisar**, com o id da requisição dentro. O `c.Log()` já carrega ele. - **A sonda `/_trilha/health/ready` ligada ao orquestrador**, e a `live` ligada ao restart — o contrário reinicia um contêiner para sempre porque o banco dele está fora. - **Um alerta sobre algo que uma pessoa sente**: taxa de erro e latência p95, não CPU. - **Nenhum dado pessoal nos logs.** Uma linha de log com um e-mail dentro é uma cópia da sua tabela de usuários num serviço de terceiro. ## As duas coisas a preparar para o dia ruim 1. **Como voltar atrás.** A imagem anterior, a tag anterior, e a certeza de que a versão anterior ainda conversa com o banco atual. 2. **Como girar o segredo.** `TRILHA_SECRET` recebe o valor novo, `TRILHA_SECRET_PREVIOUS` o antigo, pelo tempo que uma sessão dura. Sessões assinadas com a chave velha continuam valendo enquanto expiram; as novas usam a chave nova. Tirar o valor antigo encerra todas as sessões de uma vez, que é exatamente o que você quer se a chave vazou. :::nota Tudo aqui é a lista de um repositório. Se a sua tem um item que esta não tem, esse item vale mais que todos estes — ele veio de uma indisponibilidade. ::: --- # Migração Source: /trilha/pt/receitas/migracao De net/http puro para Trilha uma rota por vez, sem reescrita — e o que olhar quando você anda entre versões menores. Ninguém reescreve um app que funciona. Este é o outro caminho: colocar o Trilha na frente, mover uma rota, publicar, e repetir até não sobrar nada para mover. ## Vindo de `net/http` Aqui está o app como ele era. Um mux com os endereços numa tabela, um handler que começa descobrindo qual endereço ele é, um template executado à mão e o tratamento de erro escrito uma vez por rota: ```go // Routes is the table every net/http app grows: one mux, one line per // address, and a handler that starts by finding out which address it is. func Routes(find func(string) (Article, bool)) *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("GET /blog/{slug}", func(w http.ResponseWriter, r *http.Request) { a, ok := find(r.PathValue("slug")) if !ok { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := page.Execute(w, a); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) } }) mux.HandleFunc("GET /api/articles/{slug}", func(w http.ResponseWriter, r *http.Request) { a, ok := find(r.PathValue("slug")) if !ok { http.Error(w, `{"error":"not found"}`, http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(a); err != nil { return } }) return mux } ``` E a cadeia que todo mundo escreve de novo — cabeçalhos, checagem de host, recover: ```go // Secure is the middleware chain: the headers, the request id, the log and // the recover that every app writes again, in the order that matters. func Secure(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.EqualFold(r.Host, "example.com") { http.Error(w, "bad host", http.StatusMisdirectedRequest) return } w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "same-origin") w.Header().Set("Content-Security-Policy", "default-src 'self'") defer func() { if rec := recover(); rec != nil { http.Error(w, "internal error", http.StatusInternalServerError) } }() next.ServeHTTP(w, r) }) } ``` ### A mesma coisa depois O endereço é onde o arquivo mora, `app/blog/slug_/page.go`, então nada é declarado duas vezes: ```go // Page is the same blog page after the move: the address is the folder it // lives in (app/blog/slug_/page.go), the layout is applied for it, the 404 // is an error it returns, and the HTML is a value instead of a string. func Page(c *trilha.Ctx) (h.Node, error) { a, err := ArticleBySlug(c.Context(), c.Param("slug")) if err != nil { return nil, err } c.SetTitle(a.Title) return h.Article( h.H1(h.Text(a.Title)), h.P(h.Time(h.Attr("datetime", a.Published.Format("2006-01-02")), h.Text(a.Published.Format("2 Jan 2006")))), ), nil } ``` ```go // GET is the same API route: no writer, no encoder, no Content-Type by // hand. The error carries its own status, and an unexpected one becomes a // problem+json body with the request id in it. func GET(c *trilha.Ctx) error { a, err := ArticleBySlug(c.Context(), c.Param("slug")) if err != nil { return err } return c.JSON(200, a) } ``` O que sumiu vale ser listado, porque é a troca inteira: | Escrito à mão antes | Para onde foi | |---|---| | `mux.HandleFunc("GET /blog/{slug}", …)` | a pasta `app/blog/slug_/` | | `http.NotFound` por rota | `return trilha.ErrNotFound`, negociado como HTML ou `problem+json` | | `w.Header().Set("Content-Type", …)` | `c.JSON`, `c.HTML`, `c.Text` | | o template, executado e conferido | o `h`, que é Go e escapa por construção | | os cabeçalhos de segurança e o `recover` | o runtime, ligados por padrão | | o layout repetido em cada template | o `layout.go` da pasta | ### Uma rota por vez Você não precisa de uma virada de chave. O app do Trilha é um `http.Handler`, e o seu mux também, então qualquer um dos dois pode estar na frente do outro: ```go // Front is how the two systems share a process while the move happens: the // old mux answers what has not been moved yet, and everything it does not // know falls through to the framework. The old middleware still wraps both, // so nothing loses its headers halfway. func Front(mux *http.ServeMux, a *trilha.App) http.Handler { mux.Handle("/", a.Handler()) return before.Secure(mux) } ``` Mova as folhas primeiro — uma página sem dependências, uma rota de API que só lê. Publique depois de cada uma. Os dois sistemas dividem o mesmo processo, o mesmo pool e o mesmo logger; uma rota está num ou no outro, nunca metade nos dois. ### Quando o app mora dentro do binário antigo O `Front` acima supõe que os dois vivem no mesmo `package main`. Muitas vezes não vivem: o que está sendo movido é uma área de um servidor maior e quer a pasta dele — `internal/crm/`, com o `app/` dele. Declare o pacote à mão lá dentro e o `trilha gen` acompanha, escrevendo o `NewApp` no mesmo pacote em vez de um `main` que ninguém pediu: ```go // Package crm is one area of a server that already exists: it has its own // app/ folder and its own package name, written by hand in this file. // `trilha gen` follows the package it finds here and writes NewApp into the // same one, so the binary that hosts it mounts the app with no registration // file of its own. package crm ``` O binário que já existe monta o app como monta qualquer outro handler: ```go // Host is the same move when the app does not live in package main: crm is a // folder of the binary that already exists, `trilha gen` wrote NewApp into // the package that folder declares, and mounting it is one line. There is no // registration file to keep by hand. The nonce goes in on the way past, // because the app renders its scripts under the host's policy. func Host(mux *http.ServeMux, nonce func(*http.Request) string) http.Handler { mux.Handle("/", crm.NewApp().Handler()) return before.Secure(withNonce(mux, nonce)) } ``` ```go // withNonce hands the app the nonce the host already published. Without it // the app invents one per request, and the policy the browser is enforcing — // the host's — has never heard of that one. func withNonce(next http.Handler, nonce func(*http.Request) string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, host.WithNonce(r, nonce(r))) }) } ``` Três coisas deixam de ser do app enquanto ele está montado ali dentro, e as três são uma linha no `app/setup.go`: ```go // Config is where an embedded app says what is not its to answer for. The // host already wrote the response headers and already published a policy with // a nonce in it, so the app writes neither: Delegated sends none of the seven, // and Nonce hands c.Nonce() the value the host's own policy names. The CSRF // names move out of the way of the host's, because two hidden fields called // _csrf on one page is a bug nobody sees until a form silently posts the wrong // token. func Config(cfg *trilha.Config) { cfg.Security.Delegated = true cfg.Security.Nonce = func(r *http.Request) string { return host.Nonce(r) } cfg.CSRF = trilha.CSRF{Cookie: "crm_csrf", Field: "_crm_csrf", Header: "X-CRM-CSRF"} } ``` O `Security.Delegated` não escreve nenhum dos sete cabeçalhos — o hospedeiro já escreveu, e dois `Content-Security-Policy` na mesma resposta é uma política que ninguém consegue explicar. O `Security.Nonce` é a outra metade: os scripts do app têm que levar o nonce que está na política do hospedeiro, e não um que ele inventou para si. E o cookie, o campo e o cabeçalho do CSRF ganham nomes próprios, para que o `_csrf` escondido do app e o do hospedeiro não sejam o mesmo campo na mesma página. A quarta é o store. Variável de pacote é compartilhada por todos os apps do processo, e agora tem mais de um ali dentro: ```go // Setup provides what the pages need. The store is a value, not a package // variable: this app is one of several in the process, and Use gives each one // back its own. func Setup(a *trilha.App) error { trilha.Provide(a, contacts.New()) return nil } ``` Não existe arquivo de registro escrito à mão, e essa é a razão: o `trilha gen --check` do CI continua pegando a pasta que alguém criou sem gerar. O `trilha dev` e o `trilha build` não valem dentro de `internal/crm` — o binário é o hospedeiro — e eles dizem isso. Veja [CLI](/trilha/pt/referencia/cli#um-app-dentro-de-um-binario-que-ja-existe). Duas coisas precisam de decisão antes: - **Sessões.** Se o app antigo tem cookie próprio, continue lendo ele num middleware enquanto o novo escreve com `SetSigned`, e tire o leitor antigo quando tudo tiver migrado. - **Arquivos estáticos.** O `public/` é servido pelo framework com URLs com hash, via `c.Asset`. Um caminho escrito à mão no HTML antigo continua funcionando; ele só não ganha o cache longo. :::dica Comece com `trilha new` num diretório vazio e copie os seus handlers para lá, em vez de acrescentar o framework à árvore que já existe. Comparar dois diretórios é mais fácil do que desembaraçar um. ::: ## Ficar com a casca antiga Migrar uma rota por vez funciona até a casca atrapalhar: a página nova está escrita em `h`, mas o cabeçalho, o menu e o rodapé são um `layout.html` que o app inteiro ainda divide. Reescrever a casca primeiro é o caminho caro. O `tmpl.Wrap` põe o novo dentro do velho: ```go //go:embed casca.html var files embed.FS // The shell is prepared once, at package load: html/template only clones a set // that has not executed yet. var casca = tmpl.Wrap(tmpl.Must(files, "*.html"), "casca", "conteudo") type dados struct{ Titulo, Nonce, CSRF string } // pagina builds the template data from the *http.Request alone — which is all a // renderer that does not know the *Ctx receives. func pagina(r *http.Request) dados { return dados{ Titulo: "Área migrada", Nonce: trilha.NonceFrom(r), CSRF: trilha.CSRFTokenFrom(r), } } // Layout puts the h body inside the old shell. func Layout(c *trilha.Ctx, children h.Node) (h.Node, error) { return casca.Node(pagina(c.Request()), children), nil } ``` O template não muda de forma — o slot é o `{{template "conteudo" .}}` que já estava lá: ```html {{define "casca"}} <section class="legado"> <nav class="sub ui-nav"><span>{{.Titulo}}</span></nav> <meta name="csrf-token" content="{{.CSRF}}"> <main id="legado-conteudo">{{template "conteudo" .}}</main> <script nonce="{{.Nonce}}">window.legado = { csrf: document.querySelector('meta[name=csrf-token]').content };</script> </section> {{end}} ``` Dois detalhes tornam isso seguro. O app não converte nada para `template.HTML`: o que o `h` renderizou foi escapado na entrada, e o `tmpl` é o único lugar que afirma isso. E a casca alcança o token de CSRF e o nonce do CSP pelo `trilha.CSRFTokenFrom(r)` e pelo `trilha.NonceFrom(r)`, que respondem a partir do `*http.Request` — a única coisa que um renderizador sem `*Ctx` recebe, inclusive o `templ`, um handler seu ou um template que o próprio app executa. Fora de uma requisição da Trilha os dois devolvem `""`. Uma casca que nunca chega ao slot — um `{{if}}` que o escondeu, o nome errado — quebra o render em vez de responder calada uma página sem conteúdo. O `examples/blog` tem uma cópia funcionando em `app/legado-`. ## Entre versões menores A regra que o projeto segue: antes do 1.0, uma versão menor pode mudar como um app novo se parece, mas a atualização está sempre escrita. Na prática, quatro passos: ```bash go get -u github.com/emersonjoe/trilha@latest go install github.com/emersonjoe/trilha/cmd/trilha@latest trilha gen # o arquivo gerado tem que bater com a versão da CLI trilha audit # entre outras coisas, ele compara CLI e biblioteca make test ``` O `trilha audit` é o que pega a divergência que ninguém percebe: um `trilha_gen.go` escrito por uma CLI mais velha serve as rotas de um `app/` mais velho. É um aviso, não um erro fatal, que é precisamente por que vale rodar. O [changelog](https://github.com/emersonjoe/trilha/blob/main/CHANGELOG.md) é a fonte do que mudou; as seções `## O que muda para você` de uma release são escritas para este momento. O que vem depois de trocar a versão é o de sempre: leia a seção, rode os testes, e se a release acrescentou uma convenção (um nome de pasta novo, um arquivo novo que passa a ser lido), o `trilha routes` imprime o que o scanner enxerga agora, que é o jeito mais rápido de conferir se ele viu o que você quis dizer. :::nota Um símbolo público nunca desaparece numa versão menor sem antes ser marcado como obsoleto em uma. A superfície versionada mora em `api/current.txt`, e uma mudança nela que não foi intencional quebra a suíte de testes do próprio framework. ::: ### Ligar os arquivos de agente num projeto que já existe O `--agents` é flag do `trilha new`, então não serve para um projeto criado antes dele existir. O comando desse caso é o `trilha agents`, e ele faz exatamente a mesma coisa — nada precisa ser recriado: ```bash go get -u github.com/emersonjoe/trilha@latest go install github.com/emersonjoe/trilha/cmd/trilha@latest trilha gen # o arquivo gerado tem que bater com a versão da CLI trilha agents --lang pt # grava AGENTS.md e CLAUDE.md trilha check # o portão único: gen, gofmt, vet, test, audit, openapi git add AGENTS.md CLAUDE.md trilha_gen.go ``` Os dois arquivos são para commitar: o agente os lê do repositório, não da sua máquina. O `trilha ctx` não precisa de nada instalado — rode uma vez para ver o mapa que o seu agente vai ler. O passo que é fácil esquecer é rodar `trilha agents` **depois de cada atualização da CLI**. O `AGENTS.md` descreve os comandos da CLI que o gravou: uma cópia da 0.36.0 manda o agente rodar `make test` e nunca menciona o `trilha check`, que chegou na 0.37.0. Uma cópia intocada é regravada em silêncio; uma que você editou faz o comando parar, e aí você escolhe: - `trilha agents --force` sobrescreve e você recoloca as suas regras, ou - você move as suas regras para o `CLAUDE.md`, que o comando nunca sobrescreve, e deixa o `AGENTS.md` como arquivo do framework — que é para isso que a divisão existe. No CI, a linha para a qual os arquivos de agente apontam é a que substitui a lista de comandos: ```yaml - run: trilha check ```