Voltar à capa
Engenharia de Sistemas Publicado em 3 de novembro de 2026 7 min de leitura

Por que eu construí o Amarra-cais

Um fork do Cais em 5 de setembro, 687 commits e 1.148 testes depois: a aposta de que mini app não precisa de SPA, e o que ela custa.

Por que eu construí o Amarra-cais

O Amarra-cais nasceu de um incômodo específico: cada mini app meu carregava uma SPA inteira para mostrar tabela e formulário. Em 5 de setembro de 2026 eu importei o Cais (Inertia + Svelte) como ponto de partida e comecei um fork HTML-first. Router, sessão, CSRF, fila de jobs e SQLite ficaram. O cliente que monta tela saiu.

Pontos-chave

  1. O começo foi um commit de chore: importar o Cais v0.11 e renomear o módulo. O trabalho que veio depois foi corte por camada: Vite, Svelte, Inertia e a validação duplicada.
  2. A aposta: o handler chama view.Write, o Drive morfa #amarra-main, e sort e filtro são GET. O app não monta estado no cliente; ele pede a página de novo.
  3. A conta fecha com o chato gerado: 23 componentes no kit, g resource e g auth na CLI e um doctor que transforma bug em check (1.148 funções de teste em Go, 203 em JS, 206 issues).

O que ficou e o que saiu

O Cais já tinha o que eu não queria reescrever: router, sessão, CSRF, fila de jobs e SQLite num arquivo só, sem CGO. O Amarra manteve essa base e mudou a camada de cima. O README do projeto resume em três frases: o browser não monta uma SPA, handlers chamam view.Write, o Drive morfa #amarra-main. Não existe Vite, Svelte ou Inertia num app gerado.

The browser does not mount a SPA. Handlers call `view.Write`. Drive morphs `#amarra-main`. There is no Vite, Svelte, or Inertia in generated apps.
— README do amarra-cais · README.md

O preço estava claro desde o começo: migrar um app Cais é reescrita de UI, e isso está escrito na doc (docs/migrate-inertia.md). Não teve conversor automático. A escolha era entre manter dois runtimes para sempre e pagar a reescrita uma vez.

A troca: um morph em vez de um runtime

O Drive intercepta clique e submit para o mesmo host por padrão e substitui só o #amarra-main. O resto do HTML vem do servidor: o layout entrega o shell, a página preenche o bloco content. A doc é explícita sobre o que isso compra: primeira carga, curl e crawler recebem a página inteira; navegação vira morph. Não existe atributo para ligar o Drive, ele já vem ligado; quem precisa de visita completa marca data-amarra-skip.

No lugar de Alpine, Stimulus ou HTMX, o framework traz o kit (23 componentes: form, input, table, modal, tabs, filters, stat, tooltip) e hooks num único script de 117 KB. O app gerado não tem bundler. Sort e filtro são GET (?q=&sort=), o servidor re-renderiza, e o componente com estado (dropdown, dialog, tabs) é hook nativo, não plugin de terceiro. Streaming também cabe: amarra/stream escreve operações nomeadas (append, replace, morph, remove, toast) com alvo por id, e o hub live usa WebSocket para o mesmo contrato.

O chato precisa ser gerado

A CLI é Rails-style de propósito: new, install, dev, test, routes, db, doctor, pwa, link/unlink e os geradores (resource, auth, job, stream, component). O g resource emite o kit inteiro, não um CRUD de exemplo com classes soltas. O layout é previsível: os geradores marcam pontos no código (// cais:seeds, // cais:live-views) e o upgrade sabe onde mexer.

go
view.Write(w, r, h.views, view.Page{
  Layout: "app",
  Name:   "dashboard",
  Data: map[string]any{
    "Title":     h.catalog.T("dashboard.title"),
    "Site":      meta.ForRequest(h.site, r),
    "CSRFToken": csrf.TokenFromRequest(r),
    "Flash":     flashMsg,
  },
}, h.cfg)
Um handler inteiro: o layout cuida do shell, a página só preenche o content website/src/content/docs/docs/how-to/pages-and-views.md:29

Cada bug virou um check

O doctor existe porque os bugs aconteceram. Cada checagem nasceu de um incidente com número: o install sair 0 com CSS quebrado (#54), styles.css não existir depois do clone (#141), o bundle vendored ficar defasado do go.mod (#320). O comentário fica no código com o número da issue, para a próxima pessoa saber por que aquele erro é fatal.

go
	// Build Tailwind so styles.css exists after clone/install (#141).
	if _, err := os.Stat(filepath.Join(dir, cssInput)); err == nil {
		_, _ = fmt.Fprintln(c.Out, "→ tailwind build (styles.css)")
		if err := runTailwindBuild(dir, false); err != nil {
			// Fail loudly: exiting 0 here left users on an unstyled app (#54).
			return fmt.Errorf("css build failed: %w — retry: amarra-cais css (NODE_ENV=production skips npm devDependencies)", err)
		}
	}
O install falha alto: sair 0 aqui deixou gente com app sem estilo (#54) internal/cli/commands.go:48

A cultura de teste acompanha: 1.148 funções de teste em Go e 203 em JS para cerca de 27 mil linhas de código, com o código de teste passando de 29 mil. O AGENTS.md do repo abre dizendo para quem ele é escrito, e não é só para gente: o leitor principal costuma ser um agente, com rg barato, leitura completa caro e arquivo truncado em duas mil linhas. Nome grepável, módulo pequeno e teste headless viram requisito de manutenção quando metade das mudanças passa por agente.

Primary reader of this repo is often an LLM agent: cheap `rg`, expensive full reads, file truncation ~2k lines. Optimize for grep-unique names, small modules, and headless tests.
— AGENTS.md do amarra-cais · AGENTS.md:3

O preço, com nome e número

Nem tudo é ganho. O shell fora do #amarra-main não é atualizado pelo morph, e isso morde: neste site, trocar o idioma deixava a nav e o toggle no idioma anterior até eu abrir uma issue no próprio framework (#397). O JS do app é cache-first no service worker, então correção em web/static/js só chega com pwa --bump (#399). O data-amarra-skip é uma decisão por link: esquecer dele vira um bug silencioso.

Também não existe estado rico no cliente, animação coreografada entre telas nem mutação offline com fila própria. Quando o app precisa disso, o Amarra não é a ferramenta. Ele foi desenhado para o caso em que a tela é uma projeção do banco e o servidor é quem decide.

O que os números dizem

Dois meses depois do fork: 687 commits, 206 issues, v0.27.0, 20 projetos neste workspace dependendo do módulo (de um tradutor de EPUB a um ERP de locação), 23 componentes no kit e um script de 117 KB no browser. A escala é a minha: cada app com um SQLite, um servidor e um writer. O Amarra não foi desenhado para outra.

Este site é o exemplo mais direto. O Sintaxe nasceu SPA (React + Vite) e hoje é Amarra: o catálogo editorial é código Go, o agendamento é um published_at no futuro, a edição em inglês é uma coluna a mais. A migração está no histórico do repositório público, com o SPA antigo no commit anterior.

O framework está aberto em github.com/puppe1990/amarra-cais

Tópicos Amarra-cais Go Arquitetura Frameworks HTML
6 visualizações
Matheus Puppe

Matheus Puppe

Engenheiro de software & fundador da Gestão Bem

Construo produtos de software de ponta a ponta — do framework Amarra-cais ao Purple Stock e ao Hora Solar — e escrevo sobre sistemas, IA e o que aprendo operando tudo em produção.

Outros ensaios

Comentários

0

Nenhum comentário ainda.

Boletim semanal

Uma seleção dos ensaios da semana sobre sistemas, IA e infraestrutura. Sem spam, cancele quando quiser.