Ir para o conteúdo
Trilha
Capítulos

Referência

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étodoDescrição
Request() *http.Requesta 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.Contextcontexto da requisição (cancelamento)
Param(nome) stringparâmetro de rota (slug_ → "slug")
Pattern() stringo 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) stringprimeiro valor do parâmetro de query
Form(nome) stringcampo do formulário (faz o parse sob demanda, com limite de tamanho)
FormErr() errorerro do parse do formulário: 400 inválido, 413 grande demais
BindJSON(&v) errordecodifica o corpo JSON; campos desconhecidos são erro (400); 413 acima do limite
Cookie(nome) (*http.Cookie, error)cookie da requisição
Accepts(ofertas...) stringa oferta que o cliente prefere (Accept, ranqueado por q), ou ""; cabeçalho ausente ou */* fica com a primeira oferta
RequestID() stringX-Request-ID recebido (até 128 de A-Za-z0-9._:-) ou um id gerado
Env() trilha.Envtrilha.Dev ou trilha.Prod
Base() stringprefixo de URL (TRILHA_BASE_PATH), sem barra final
App() *trilha.Appa aplicação
Fragment() stringid que o cliente quer trocar (cabeçalho Trilha-Fragment), ou "" numa navegação normal (Interatividade)

Resposta#

MétodoDescrição
JSON(code, v) errorescreve JSON com Content-Type correto
Text(code, s) errorescreve texto simples
HTML(code, node) errorescreve um nó como documento inteiro, sem layouts
Redirect(url) errordevolve o erro de redirecionamento 303 (use com return)
IsPrefetch() booleste GET é um prefetch (Purpose: prefetch do ui.Prefetch, ou o Sec-Purpose do navegador): pule efeitos colaterais da leitura; uma leitura que precisa ser sempre registrada deixa os links de fora com ui.NoPrefetch
RedirectReload(url) errorRedirect para um destino com outra moldura (entrar, sair, organização): o cliente que segue redirects no lugar o carrega inteiro
PushURL(caminho), ReplaceURL(caminho)numa resposta de fragmento, o endereço que reconstrói o que ela desenhou: entrada nova no histórico, ou a entrada que o gatilho criou; ReplaceURL("") deixa a barra como está. Um caminho deste site, como o Redirect; nada numa página inteira
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() []Flashos 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) errorescreve 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() *Streamresposta em Server-Sent Events: Send(evento, dados), JSON(evento, v), Comment(s), Flush(), Done(); desliga o write timeout (IA e agentes)
Writer() http.ResponseWriteracesso direto (downloads longos, WebSocket)
Written() boolse a resposta já começou

Cache HTTP#

MétodoDescrição
ETag(tag) boolescreve ETag (com aspas, se faltarem) e diz se a requisição já a tinha
LastModified(t) boolescreve 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étodoDescrição
SetTitle(s) / Title() stringtítulo da página, lido pelos layouts
Set(chave, v) / Get(chave) anyvalores por requisição (middleware → página → layout)

Ilhas#

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, island). 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).

O caminho de volta: o objeto island#

O terceiro argumento é o canal para o servidor. Ele existe para a ilha não ter que redescobrir o token, os cabeçalhos e o formato de erro que o resto do framework já combinou:

MembroO que faz
island.csrf()o token desta resposta — o mesmo que o CSRFInput põe em todo formulário
island.signalum AbortSignal, abortado quando o elemento sai da página
island.get(url)lê JSON
island.post(url, dados)manda JSON, ou envia Blob, File, FormData e ArrayBuffer como corpo nativo, sempre com o token
island.send(metodo, url, dados)o mesmo, para PUT, PATCH e DELETE
island.swap(url, id)troca um fragmento, como um link com alvo faz

Uma rota que responde 422 volta como IslandInvalid, e o .fields dela é o mesmo objeto que o formulário mostraria; qualquer outra falha é um IslandError com .status e .detail. Um Trilha-Location na resposta é seguido como navegação, então POST → redirect → GET também funciona de dentro de uma ilha: o runtime manda Trilha-Island: 1, e o handler que responde c.Redirect a esse pedido responde 204 + Trilha-Location, como faz para um fragmento, em vez de um 303 que o fetch seguiria calado (0.156.0). O flash que o handler deixou espera no cookie pela página para onde o navegador vai.

O cookie de dupla submissão é HttpOnly, então o token chega à ilha escrito no elemento, como data-trilha-csrf. É ele que o island.post manda no X-CSRF-Token.

Um route.go é API, e o cliente de API leva um bearer, não um cookie — então o pedido com cabeçalho Authorization, ou sem cookie nenhum (um webhook), não precisa do token. O pedido que chega com os cookies do navegador e sem Authorization é a página — uma ilha, ou o formulário de outro site pegando carona na sessão — e, desde a 0.156.0, é conferido como o POST de uma página, sem middleware para lembrar. O trilha.RequireCSRF continua existindo para a rota que quer a conferência seja qual for o pedido.

A rota continua sendo API, então os erros dela continuam em problem+json — que é o que a ilha consegue ler.

Módulo que importa módulo#

O c.Island entrega ao navegador a URL versionada do módulo (/jogo/ilha.js?v=…), para um deploy nunca rodar a página nova com o módulo velho do cache. O módulo que importa outro por caminho relativo — import { passo } from "./motor.js" — perderia isso: o navegador resolve ./motor.js a partir da URL do módulo e pede o arquivo sem a versão. Por isso a página com ilhas leva um import map, escrito antes de qualquer script, que leva cada módulo de public/ alcançado pelas ilhas por import relativo ou da raiz (./, ../, /) à URL versionada dele. Nada muda no módulo; o import nu (import x from "preact") fica como está, e o arquivo do trilha vendor é importado pelo caminho como qualquer outro. A ilha que chega num fragmento, numa página que não tinha nenhuma, fica sem mapa — o módulo dela continua versionado, os imports dele não.

Os tipos que o módulo enxerga#

O script que a página carrega para tudo isso é o trilha.IslandRuntime (/ui.island.js), gravado em public/ pelo trilha ui junto com o resto do kit; o trilha check avisa quando um projeto monta uma ilha sem ele.

O trilha gen grava public/islands.d.ts a partir das chamadas c.Island que acha em app/: uma interface por struct de props, mais o objeto island e a assinatura da função de montagem. As props são tipadas pelo tipo da expressão — um literal, uma variável ou um parâmetro da função, &props, uma conversão, ou uma função do app que devolve o struct —, então um struct pode alimentar várias ilhas escolhidas por um switch. Aponte o módulo para lá e o editor confere os dois lados da fronteira:

/** @type {import("/islands.d.ts").IslandMount<"/editor.js">} */
export default function (el, props, island) { … }

O campo cujo tipo mora em outro pacote do mesmo módulo — []api.Carta, *contrato.Jogada — é seguido até esse pacote, e o editor enxerga Carta e os campos dela também; o tipo de fora do módulo (uuid.UUID) fica unknown, e o trilha gen diz o campo e o módulo de onde ele veio.

Props escritas como literal de mapa ou como variável não têm nome onde pendurar um tipo: a ilha continua declarada, com tipo unknown, e o trilha gen avisa. O gen --check compara o arquivo como compara o trilha_gen.go, e o app que perdeu a última ilha perde o arquivo.

Conexão longa e corpo grande#

MétodoDescrição
AllowBody(n int64)limite de corpo desta requisição, no lugar do Config.MaxBodyBytes
NoReadDeadline() errortira o prazo de leitura desta requisição (upload lento não é erro)
NoWriteDeadline() errortira 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:

// 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.

Config.MaxBodyBytes é o teto da requisição. Config.MaxFormMemory é outro limite: o parser multipart mantém no máximo essa quantidade de dados de arquivo na RAM (32 MiB por padrão) e derrama o restante em arquivos temporários. Elevar uma rota de upload para 200 MiB, portanto, não transforma silenciosamente cada upload aceito em 200 MiB de heap.

c.Standalone() informa a última declaração do navegador de que a PWA está rodando instalada. A receita pwa mantém o cookie lido por ele. Use apenas para esconder ou mudar a interface de instalação, nunca para autorização nem identidade do aparelho.

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:

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étodoDescrição
CSRFToken() stringtoken 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) stringo 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) stringo nonce da CSP daquela requisição, mesmo motivo e mesma regra (Segurança)

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", depois pela json:"nome", depois pelo nome do campo — um struct que veio de uma API tem tag json e não tem form, e um formulário que postava nome para um campo que o binder chamava de Nome fazia o required disparar sobre um valor que alguém digitou; 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.

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ímboloPapel
FileRules.MaxSize int64limite deste arquivo, à parte do Config.MaxBodyBytes; 0 deixa o limite do corpo trabalhar
FileRules.Accept []stringtipos aceitos, comparados com o tipo detectado: "image/png", "image/*", "*/*"; vazio aceita qualquer um
FileRules.Optional boolcampo ausente devolve (nil, nil) em vez de erro
FileRules.MaxFiles intteto de quantos arquivos o Files aceita numa requisição; 0 é sem teto
Upload.Namenome sanitizado: sem diretório, sem separador, sem caractere de controle, no máximo 100 caracteres, nunca vazio
Upload.MIME / Upload.Exttipo detectado nos primeiros 512 bytes, e a extensão correspondente
Upload.Size / Upload.Filetamanho 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() errorfecha 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, filecount) — veja Validação.

Files(campo string, regras FileRules) ([]*Upload, error) lê todos os arquivos que o campo carrega, na ordem em que o navegador mandou, aplicando as mesmas regras a cada um. Um campo de arquivo vazio não é um arquivo: ele sai antes da contagem, então Optional continua querendo dizer "ninguém escolheu nada". O arquivo que falha nomeia a própria posição — files[2], não files — então um formulário com uma linha por arquivo põe a mensagem na linha certa; os que passaram voltam na fatia, e fechá-los é com quem chamou. Acima do MaxFiles a requisição inteira é recusada no nome do campo, antes de um byte ser lido.

Rascunhos#

Draft(nome string) *Draft é um formulário em andamento, guardado de uma requisição para a outra. É a resposta à única pergunta difícil de um formulário em passos: onde mora o passo 1 enquanto a pessoa está no passo 2.

SímboloPapel
c.Draft(nome)nomeia o formulário — não a pessoa; o rascunho viaja no cookie dela
d.Save(v any, ttl time.Duration) errorescreve o rascunho e começa o prazo
d.Load(v any) errorpreenche v, ou ErrNoDraft — nunca salvo, terminado, vencido, mexido, ou de outro navegador
d.Clear()o rascunho virou registro e deixa de existir
Config.Drafts DraftStoreonde fica um rascunho acima de 2 KB de JSON; nil é só o cookie

Abaixo de 2 KB de JSON o rascunho é um cookie assinado: nada para configurar, nada para limpar, e vence sozinho. Acima disso ele precisa do Config.Drafts — três métodos sobre o que a app já roda — e, sem ele, o Save devolve um erro citando esse campo em vez de mandar um cookie que o navegador descartaria sem avisar. (O limite é 2 KB e não os 3 KB que a issue propunha porque o que vai no cookie é o base64 do rascunho mais o prazo e a assinatura, e 3 KB de JSON passam dos 4 KB do navegador.)

ErrNoDraft é uma resposta, não uma falha: é o que manda a pessoa de volta ao passo 1. Rascunho escrito por uma versão anterior da struct responde igual — o campo mudou de nome entre dois deploys, e recomeçar é melhor que um 500 no meio do formulário de alguém.

Rascunho é assinado, então não dá para editá-lo à mão, e não é secreto: o que está num cookie viaja para o navegador e pode ser lido lá. Preço ou o nome de outra pessoa ficam atrás do Config.Drafts, com só a chave no cookie. Assinar precisa do TRILHA_SECRET; sem ele, o Save avisa em vez de falhar calado.

O ui.Steps desenha o indicador — veja Formulário em passos para o fluxo inteiro.

Planilhas#

CSV(name string, rows any) error escreve uma fatia — ou um canal de recebimento — como um arquivo que a pessoa abre, e BindCSV(r io.Reader, dst any, rules ...CSVRules) (CSVResult, error) lê um de volta dizendo qual célula está errada.

SímboloPapel
c.CSV(name string, rows any) errordownload com BOM UTF-8, o separador do locale e linhas em CRLF
csv:"Cabeçalho"o cabeçalho da coluna; sem tag é o nome do campo, e csv:"-" deixa o campo de fora
BindCSV(r io.Reader, dst any, rules ...CSVRules)lê o arquivo num *[]T, validando cada linha com as tags validate dela
CSVRules.MaxRows intteto do arquivo; padrão 100 mil, e acima dele um erro em vez de um corte
CSVRules.Separator runeforça o delimitador; zero detecta pelo cabeçalho
CSVResult.Rows intquantas linhas de dados foram lidas, fora as em branco
CSVResult.Errors []CSVError{Line, Column, Message} na ordem do arquivo; o cabeçalho é a linha 1
CSVResult.Warnings []stringo que foi estranho e não fatal — cabeçalho que nenhum campo reivindica
res.OK() boolsem erros: todas as linhas podem ser usadas

O Config.Locale decide o separador (, em en, ; em pt-BR), a data (2006-01-02 15:04 contra 02/01/2006 15:04), a marca decimal e a palavra do booleano (yes/no, sim/não); o Config.TimeZone decide em que dia um horário cai. O BOM não é opcional nem configurável: sem ele o Excel lê todo acento como caractere estranho, e é a primeira coisa que qualquer um percebe.

Célula de texto que começa com =, +, -, @, tab ou CR sai com um apóstrofo na frente ('=HYPERLINK(…)), para a planilha ler texto em vez de rodar uma fórmula que alguém digitou no app (injeção de CSV); o BindCSV tira o apóstrofo na volta.

Um canal é o que uma exportação de duzentas mil linhas quer — as linhas são escritas conforme chegam e o prazo de escrita é retirado —, mas encerrar o produtor é com você: faça select no c.Context().Done(), ou um navegador que fechou a conexão deixa uma goroutine para trás. (iter.Seq não é aceito: este módulo compila no Go 1.22.)

Na volta, o separador e o BOM são detectados, o cabeçalho casa pela tag em qualquer ordem ignorando maiúsculas e espaços em volta, linha em branco é pulada, e a data é lida em dd/mm/aaaa além do ISO. Só as linhas que passam entram na fatia, então depois do res.OK() ela é o arquivo inteiro. Coluna obrigatória que falta no cabeçalho é uma mensagem na linha 1, não a mesma mensagem em toda linha; cabeçalho que nenhum campo reivindica é aviso, porque planilha ganha coluna o tempo todo. O ui.CSVErrors desenha a lista — veja Planilhas (CSV) para a ida e a volta inteiras.

Junto com o ui.Flashes(c) — a linha de resumo, e a tabela do que corrigir embaixo — é a resposta que uma tela de importação dá sem a pessoa abrir a planilha para achar uma data errada no olho.

app/…/page.go
res, err := trilha.BindCSV(up, &linhas)
if !res.OK() {
	return c.Render(422, page(ui.CSVErrors(c, res, ui.CSVErrorsOpts{
		Action: ui.ButtonLink("/importar", h.Text("Escolher outro arquivo")),
	})))
}
c.Flash(ui.FlashSuccess, "3 linhas importadas, 2 rejeitadas")
resultado
3 linhas importadas, 2 rejeitadas
LinhaColunaProblema
4due_datenão é uma data válida
9status"cancelado" não é um status conhecido
coluna "notas" não é usada

Mandando um arquivo#

O File recebe; estes seis mandam. O nome, o tipo e os dois cabeçalhos que impedem um download de virar uma página da sua origem são tudo o que há.

SímboloPapel
Attachment(nome string, corpo io.Reader, tipo string) errordownload: Content-Disposition: attachment
Inline(nome string, corpo io.Reader, tipo string) erroro navegador abre ali mesmo (um PDF num <iframe>, uma imagem)
Send(nome string, corpo io.Reader, tipo string, o SendOpts) erroros mesmos dois, com o que três argumentos não sabem dizer
AttachmentFile(caminho, tipo string) errorabre o arquivo, manda e fecha; ausente é 404, diretório é erro
InlineFile(caminho, tipo string) erroro mesmo, aberto no visor
Pipe(res *http.Response) errorentrega ao navegador a resposta de outro serviço e fecha o corpo dela
SendOptsPapel
Inline boolabrir ali mesmo em vez de baixar — a diferença entre os dois métodos acima
Size int64o tamanho inteiro de um corpo que não busca posição: a resposta promete Accept-Ranges e responde Range com 206
ContentRange stringo corpo já é o pedaço: o Content-Range do 206 de outro serviço
NeutralizeScript boolmandar inline um documento com script, com o script desligado

O nome passa pela mesma função que saneia o de um upload: um caminho nunca vira nome de arquivo, e nada dentro dele acrescenta uma segunda linha ao cabeçalho. Ele sai duas vezes — filename*=UTF-8'' percent-encoded para quem lê a RFC 5987 e um filename ASCII entre aspas para quem não lê — porque nome com acento quebra em metade dos navegadores quando sai uma vez.

Um tipo vazio é detectado nos primeiros 512 bytes, como o File fareja um upload, com a extensão podendo afinar dentro da mesma família (text/plain para text/csv) e nunca contrariar. O X-Content-Type-Options: nosniff sai sempre: um download que o navegador pode reinterpretar é um download que vira página desta origem.

O Inline só aceita o que um visor mostra — application/pdf, image/* (SVG não), audio/*, video/*, text/plain, text/csv. HTML, SVG e XML voltam como erro, não como resposta: são documentos com script, servidos da sua própria origem. O trilha.CanInline(ctype) responde a mesma pergunta antes da chamada, para uma tela oferecer o "ver" só onde há o que ver.

A recusa vem embrulhada no trilha.ErrCannotInline, que é o que deixa a tela distinguir um conteúdo que não se mostra de um bug no próprio código:

if err := c.Inline(up.Nome, corpo, up.MIME); errors.Is(err, trilha.ErrCannotInline) {
	return c.Render(http.StatusOK, semVisor(up)) // a tela desenha o próprio cartão
}

A resposta é que diz que pode ser enquadrada por uma página desta origem, e é essa a metade que todo mundo erra. O endurecimento padrão manda X-Frame-Options: DENY e frame-ancestors 'none' em toda resposta; um documento que carrega isso não aparece no lugar, não importa o que a página em volta declare. Acrescentar frame-src na página que enquadra não muda nada — quem recusa é a resposta enquadrada, e o console diz isso. O Inline afrouxa esses dois cabeçalhos naquela resposta só, e deixa em paz o app que escreveu o próprio Security.CSP.

Quem desenha é o ui.Preview: a barra, o quadro, o cartão para o tipo que ninguém mostra, e um <img> no lugar do quadro quando é imagem.

Um corpo que é io.ReadSeeker — um bytes.Reader, um os.File — sai pelo http.ServeContent, então Range, If-Range, 304 e HEAD vêm de graça e o Accept-Ranges: bytes é prometido. Qualquer outro é copiado em stream e não promete nada — a não ser que você diga o tamanho dele, que é a seção seguinte. Todo envio desliga o write deadline: um arquivo de 50 MB numa linha ruim não é um handler lento.

Range num corpo que não busca posição#

O corpo da resposta de outro serviço nunca busca posição, e é justamente esse o app que um frontend com Upstreams é: uma tela Trilha na frente de um backend que já existe. Sem 206, o <audio> e o <video> no Safari do iOS não posicionam — "pular para o minuto 3" vira baixar o arquivo inteiro — e o app acaba escrevendo o par Range/Content-Range à mão, fora do envelope.

O SendOpts.Size é o tamanho inteiro desse corpo, o Content-Length que o outro serviço respondeu:

res, err := http.DefaultClient.Do(req)
if err != nil {
	return err
}
defer res.Body.Close()
return c.Send("episodio.mp3", res.Body, res.Header.Get("Content-Type"), trilha.SendOpts{
	Inline: true,
	Size:   res.ContentLength,
})
RequisiçãoResposta
sem Range200 com Content-Length e Accept-Ranges: bytes — é ela que ensina o tamanho ao navegador e o autoriza a pedir pedaços
Range: bytes=10-19206, Content-Range: bytes 10-19/tamanho, Content-Length: 10, aqueles dez bytes
bytes=900-, bytes=-10206 cortado numa ponta ou na outra
Range fora do arquivo, ou que não parseia416 com Content-Range: bytes */tamanho
mais de um intervalo200 com o arquivo inteiro — resposta legal para qualquer Range, e multipart/byteranges não paga num stream

O prefixo é jogado fora à medida que chega (io.CopyN para io.Discard) e o pedaço para no último byte. Ler o arquivo inteiro para a memória para ganhar um io.ReadSeeker custaria o áudio inteiro a cada pedido de 64 KB do meio, que é a troca que isto existe para evitar — mas o prefixo viaja do outro serviço até aqui. Quando esse serviço sabe responder Range, repasse o cabeçalho e devolva o 206 dele, que aí nada viaja duas vezes:

// o Range desta requisição foi repassado ao outro serviço
return c.Send(nome, res.Body, res.Header.Get("Content-Type"), trilha.SendOpts{
	Inline:       true,
	ContentRange: res.Header.Get("Content-Range"), // "bytes 10-19/4096"
})

O valor é conferido antes de virar cabeçalho nosso — um Content-Range montado errado é um arquivo corrompido no navegador —, o status vira 206, o Content-Length sai calculado do intervalo e nada é cortado. Size e ContentRange juntos são erro: são duas afirmações diferentes sobre o mesmo corpo. Sem nenhum dos dois, um corpo que não busca posição continua saindo inteiro com 200: o kit não pode inventar o total de um Content-Range, e prometer Range sem saber o tamanho é pior do que não prometer.

Um SVG servido com o script desligado#

Recusar image/svg+xml inline está certo — para o navegador um SVG é documento com script, e servir script da própria origem é XSS armazenado com outro nome. Mas um produto white label aceita logotipo em SVG, e esse logotipo tem de aparecer na tela de entrada. O NeutralizeScript é a forma explícita de dizer "eu sei o que é um SVG", sem sair do envelope:

return c.Send(logo.Nome, corpo, logo.MIME, trilha.SendOpts{
	Inline:           true,
	NeutralizeScript: true,
})

A resposta sai com a política que o kit impõe, a mesma para todo app:

Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'self'

Nada de script, nada de buscar nada, estilo embutido (sem ele um SVG deixa de ser desenho), enquadrável pela própria origem. Ela é escrita por cima do Security.CSP do app: aqui a política não é endurecimento, é a condição para o documento sair. E ela abre a porta do script e nenhuma outra — um application/zip inline continua recusado, com ou sem a opção, porque o que um visor mostra é outra lista.

O Pipe copia o status e uma lista fechada de cabeçalhos — Content-Type, Content-Disposition, Content-Length, Content-Encoding, Content-Range, Accept-Ranges, Cache-Control, ETag, Last-Modified, Expires, Vary, Age, X-Robots-Tag — mais os nomes em Config.PipeHeaders, para um upstream cujo cabeçalho próprio o app quer manter. O Set-Cookie não viaja, listado ou não, nem o Content-Security-Policy e o Strict-Transport-Security: o corpo de outro serviço não senta na sessão deste nem passa por cima da política dele. Para um prefixo inteiro encaminhado a outro serviço, veja Upstreams; o Pipe é a resposta que você mesmo foi buscar.

Idioma do pedido#

ChamadaO que responde
c.Locale() stringo idioma desta requisição: "pt-BR", "ht", "fr"
c.SetLocale(l string)força o idioma no resto da requisição, sem gravar cookie
c.T(key string, args ...any) stringa mensagem do próprio app naquele idioma

Só com o Config.Locale é um idioma para o processo inteiro. Com o Config.Locales ele é negociado por requisição — a preferência que o app guardou (Config.LocaleOf), o ?lang=, o cookie trilha_lang que ele deixa, o Accept-Language, o padrão — e resolvido uma vez só: uma página com cinquenta datas lê o cabeçalho uma vez. Veja Vários idiomas para a ordem e para o catálogo.

func Page(c *trilha.Ctx) (h.Node, error) {
	c.SetTitle(c.T("atendimento.titulo"))
	return h.Div(
		h.P(h.Text(c.T("atendimento.bemvindo", nome))),
		h.P(h.Text(c.T("atendimento.protocolos", n))), // "1 protocolo pendente" / "4 pendentes"
		h.A(h.Href("?lang=ht"), h.Text("Kreyòl")),
	), nil
}

O c.T procura a chave no c.Locale(), depois no que o Catalog.Fallback aponta, depois no idioma padrão. Chave que ninguém definiu volta como a própria chave e é logada uma vez — nunca volta vazia, e o trilha check reprova antes de uma tela mostrar isso. Sem o Config.Catalog, o c.T devolve a chave: um app de um idioma só não precisa de catálogo para compilar.

c.Link(nome, opts) monta uma URL assinada que funciona sem sessão, e c.Claim(nome) é o que a rota do outro lado chama.

SímboloPapel
c.Link(nome, LinkOpts{...})a URL; nome é o fim, e link de um fim não abre outro
LinkOpts.Dataviaja dentro do token: assinado, não secreto
LinkOpts.TTLzero é uma hora; negativo é erro, não padrão
LinkOpts.Useszero é ilimitado e não precisa de estado nenhum
c.Claim(nome)confere assinatura, fim, prazo e usos restantes
link.Consume()gasta um uso — depois do trabalho, nunca antes
Config.Linksum trilha.LinkStore que conta os usos dos links com limite — um INCR comparado com o limite, que é o que faz o Redis ser o natural; nil conta no processo, o que é honesto sobre uma réplica e é dito uma vez no log
trilha.ErrNoLinko que o Claim responde para um token inválido, de outro link, vencido ou esgotado — um erro para os quatro casos, de propósito

Toda forma de um link falhar responde o mesmo 404. Assinatura errada, fim errado, vencido, já gasto: dizer a um estranho qual das quatro aconteceu é dizer o quão perto ele está. Token errado também custa ao endereço um ponto de um orçamento pequeno, porque adivinhar token em URL é força bruta.

Esse orçamento se recompõe, e não é um bloqueio, o que é uma diferença deliberada em relação à issue que pediu isto: uma hora de bloqueio por endereço transforma uma pessoa desastrada atrás do NAT de um escritório numa queda para todo mundo atrás dele — e a propriedade que importa, adivinhar ficar inviável, é a mesma nos dois casos.

Com Uses: 0 não há estado. Nem linha, nem consulta, nem limpeza: conferir é checar uma assinatura. É o caso do código de verificação impresso num documento, e é por isso que o fluxo comum não precisa de tabela.

Ler não é gastar. O Claim confere que sobrou uso; só o Consume tira um. Senão um recarregar queimaria o link de quem ainda está preenchendo, e um erro de validação custaria o convite.

Veja Link público para o fluxo inteiro.