# Trilha > A Next.js-style web framework for Go: a folder under app/ is a route, HTML is written in Go, and nothing outside the standard library is imported. --- # Quick start Source: /trilha/learn From zero to a page in the browser in five minutes, and what happened at each step. In this trail you build an **events agenda**: a list, a detail page, a form to register events, a JSON API and a restricted area. Each chapter adds one piece and ends with a challenge. This first one only gets the project standing. ## What you need - Go 1.22 or newer (`go version`). - Go's binary directory on your `PATH`: `~/go/bin` (Go installs programs there, not in `/usr/local/go/bin`, which is where `go` itself lives). ```bash # if `trilha` is not found after go install, add this to your ~/.zshrc or ~/.bashrc: export PATH="$HOME/go/bin:$PATH" ``` ## Install and create the project ```bash go install github.com/emersonjoe/trilha/cmd/trilha@latest trilha new agenda cd agenda trilha dev ``` Open `http://localhost:3000`. The home page is already there. Leave `trilha dev` running: it recompiles and reloads the browser every time you save a file. ## What was created ```text agenda/ ├── go.mod ├── trilha_gen.go ← generated by the CLI; commit it, do not edit it ├── public/style.css ← served at /style.css └── app/ ├── layout.go ← the of every page ├── page.go ← GET / ├── not_found.go ← 404 page └── api/hello/route.go ← GET /api/hello ``` The rule that holds everything together: **a folder inside `app/` is a path in the URL**. The file inside it says what that path does. ## Your first page Create `app/events/page.go`: ```go package events import ( "github.com/emersonjoe/trilha" "github.com/emersonjoe/trilha/h" ) func Page(c *trilha.Ctx) (h.Node, error) { c.SetTitle("Events") return h.Fragment( h.H1(h.Text("Upcoming events")), h.P(h.Text("No events registered yet.")), ), nil } ``` Save and visit `/events`. Three things happened: 1. The CLI saw the new folder, regenerated `trilha_gen.go` with the `/events` route and recompiled. 2. `Page` ran and returned an HTML **node**, built with the `h` package. 3. The node was handed to the `Layout` in `app/layout.go`, which wrapped it in ``, and the result was sent with `Content-Type: text/html`. :::tip `Page` takes a single argument, the `*trilha.Ctx`, and returns `(h.Node, error)`. Every route function in Trilha follows this shape: one context in, one error out. You will see the same design in layouts, middleware and API routes. ::: ## Challenge Create `app/about/page.go` answering `/about` with a heading and a paragraph, and add a link to it in the navigation of `app/layout.go`. When you save, the page should appear without restarting anything. :::solution ```go // app/about/page.go package about import ( "github.com/emersonjoe/trilha" "github.com/emersonjoe/trilha/h" ) func Page(c *trilha.Ctx) (h.Node, error) { c.SetTitle("About") return h.Fragment( h.H1(h.Text("About the agenda")), h.P(h.Text("An events agenda built with Trilha.")), ), nil } ``` In `app/layout.go`, inside `h.Nav(...)`: ```go h.A(h.Href("/about"), h.Text("About")), ``` ::: --- # Pages and routes Source: /trilha/learn/pages-and-routes How folders become URLs, including dynamic segments, catch-all and groups. You have seen that `app/events/page.go` answers `/events`. This chapter covers the rest of the mapping: URL parameters, paths of variable length and folders that group pages without showing up in the URL. ## Dynamic segment: `name_` Each event gets its own page at `/events/go-meetup`. Instead of one folder per event, create a folder whose name ends with `_`: ```text app/events/slug_/page.go → GET /events/{slug} ``` Inside the page, the value comes from `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("Event " + slug) return h.H1(h.Textf("Event: %s", slug)), nil } ``` The parameter name is the folder name without the `_`. A folder `id_` gives `c.Param("id")`. :::note Why not `[slug]` like other frameworks? Because the folder becomes a **Go package**, and a package import path accepts neither brackets, braces nor dollar signs. The `_` suffix is legal, shows up in `go list ./...` and does not confuse the shell. ::: ## Catch-all: `name__` Two underscores at the end capture everything that follows, inner slashes included: ```text app/docs/path__/page.go → GET /docs/{path...} ``` `GET /docs/guide/install` arrives with `c.Param("path") == "guide/install"`. A catch-all folder must be a leaf: nothing can exist below it. ## Who wins on a tie Literal routes beat dynamic ones. With `app/events/new/page.go` and `app/events/slug_/page.go`, `/events/new` goes to the first and `/events/anything-else` to the second. Two sibling dynamic folders (`a_` and `b_` at the same level) are a generation error, because there would be no way to choose. ## Route groups: `name-` Sometimes you want several pages to share a layout or a middleware without that showing in the URL. A folder ending in `-` is a **group**: ```text app/organizer-/middleware.go ← applies to everything below app/organizer-/dashboard/page.go → GET /dashboard (no "organizer" in the URL) app/organizer-/events/page.go → GET /events ✗ conflicts with app/events/page.go ``` The generator refuses two folders that produce the same URL (`E_DUPLICATE_ROUTE`), so the second example above does not compile. ## Letting the CLI do the translation Nothing above needs to be typed by hand. `trilha generate` takes the URL and writes the folder the convention asks for, already compiling: ```bash trilha generate page /events/{slug} # app/events/slug_/page.go trilha generate route /api/events # app/api/events/route.go ``` The page comes with `c.Param("slug")` already read, and `trilha_gen.go` is regenerated at the end, so the URL answers before you open the editor. With `--methods`, `--bind` and `--form` the skeleton also comes with the contract — the handlers, the struct, the validation and the form — and `trilha generate test ` writes the test beside it. The flags are in [CLI](/trilha/reference/cli#trilha-generate). ## What the generator does with this Run `trilha routes` at any time to see the table: ```text METHODS PATTERN SOURCE GET / app/page.go GET /events app/events/page.go GET /events/{slug} app/events/slug_/page.go GET /dashboard app/organizer-/dashboard/page.go ``` That table becomes Go code in `trilha_gen.go`: one `a.Register(trilha.Route{...})` per line, importing each package. If you rename `Page`, the compiler complains, not the server in production. ## Challenge Create the detail page `app/events/slug_/page.go` showing the slug, and a page `app/events/today/page.go`. Confirm with `trilha routes` that `/events/today` points to the literal folder and not to the dynamic one. :::solution Both pages follow the `Page` shape. The output of `trilha routes` must contain: ```text GET /events/today app/events/today/page.go GET /events/{slug} app/events/slug_/page.go ``` Alphabetical order puts `/events/today` first, but what decides precedence is the router: literal before dynamic, always. ::: --- # Nested layouts Source: /trilha/learn/layouts One layout per folder, from the innermost to the outermost, and how the title travels between them. A `layout.go` wraps every page in its folder and in the folders below. The layout in `app/` is the root and is usually the only one that writes ``. ## The signature ```go func Layout(c *trilha.Ctx, children h.Node) (h.Node, error) ``` `children` is the page already rendered as a node, or the innermost layout already applied. You decide where to place it. ## A layout for the agenda Create `app/events/layout.go`: ```go package events 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("/events"), h.Text("All")), h.A(h.Href("/events/new"), h.Text("New event")), ), children, ), nil } ``` Now `/events`, `/events/new` and `/events/anything` appear inside that `
`, which in turn appears inside the `
` of the root layout. @demo layout ## Execution order For `GET /events/go-meetup`: 1. `app/events/slug_/page.go` → `Page` produces the page node. 2. `app/events/layout.go` → receives that node as `children`. 3. `app/layout.go` → receives the result of step 2. Inside out. A folder without `layout.go` simply does not take part. ## Title and other page data for the layout The page runs **before** the layouts. That is why `c.SetTitle("Events")` in the page works in the root layout, which reads `c.Title()` to build the ``. The same goes for any value you store with `c.Set(key, value)` and read with `c.Get(key)`. ```go // in the page c.SetTitle("Go Meetup") c.Set("description", "An evening of talks in Campinas") // in the root layout h.Title(h.Text(c.Title())), h.Meta(h.Name("description"), h.Content(str(c.Get("description")))), ``` :::tip If there is no `app/layout.go`, Trilha wraps the page in a minimal `<html>`. Handy in the first minutes; create your own as soon as you want CSS. ::: ## Layouts in route groups A group (`organizer-/`) may have a layout. It applies to the pages of the group and counts as one level in the order: `page → group layout → root layout`. ## Challenge Make the layout of `app/events/` show, below the navigation, a `<p>` with the title of the current page, to confirm that the title set in `Page` is already available there. :::solution ```go func Layout(c *trilha.Ctx, children h.Node) (h.Node, error) { return h.Section(h.Class("agenda"), h.Nav( h.A(h.Href("/events"), h.Text("All")), h.A(h.Href("/events/new"), h.Text("New event")), ), h.P(h.Class("crumb"), h.Text(c.Title())), children, ), nil } ``` ::: --- # HTML with the h package Source: /trilha/learn/html-with-h Elements as functions, escaping by default, conditionals, lists and when to use templates. The `h` package produces HTML without template files: each element is a Go function that accepts attributes and children in any order. Everything is checked by the compiler and escaped on output. ## Elements, attributes and text ```go h.Article(h.Class("event", "featured"), h.H2(h.Text(ev.Name)), h.P(h.Textf("%s, %d seats", ev.City, ev.Seats)), h.A(h.Href("/events/"+ev.Slug), h.Text("Details")), ) ``` - `h.Text` and `h.Textf` escape. `h.Raw` does not, and it is the only door for ready-made HTML. - Attributes (`h.Class`, `h.Href`, `h.ID`, `h.Data("x", v)`, `h.Attr("name", v)`) may come after the children; they always end up in the opening tag. - Void elements (`h.Br`, `h.Img`, `h.Input`, `h.Meta`) do not close. - Boolean attributes are functions without arguments: `h.Required()`, `h.Disabled()`. - When a name collides with an element, the attribute gets the `Attr` suffix: `h.StyleAttr`, `h.TitleAttr`, `h.LabelAttr`. @demo escape ## Conditionals and lists ```go h.Ul( h.If(len(events) == 0, h.Li(h.Em(h.Text("no events")))), h.Map(events, func(ev Event) h.Node { return h.Li(h.Text(ev.Name)) }), ) ``` `h.If` returns an empty node when the condition is false; `h.IfElse` picks one of two; `h.Map` applies a function to each item; `h.Fragment` groups several nodes without a wrapping element. `nil` as a child is ignored, so a `func() h.Node` returning `nil` is safe too. @demo lista ## Components are functions There is no "component" type. A function returning `h.Node` already is one: ```go func EventCard(ev Event) h.Node { return h.Article(h.Class("card"), h.H3(h.Text(ev.Name)), h.P(h.Text(ev.City)), ) } // in the page h.Div(h.Class("grid"), h.Map(events, EventCard)) ``` ## Prefer templates? The `tmpl` package plugs `html/template` into the same pipeline. The files sit next to the page and are embedded in the binary: ```go package report import ( "embed" "github.com/emersonjoe/trilha" "github.com/emersonjoe/trilha/h" "github.com/emersonjoe/trilha/tmpl" ) //go:embed report.html var files embed.FS var t = tmpl.Must(files, "*.html") // fails at startup, never during a request func Page(c *trilha.Ctx) (h.Node, error) { c.SetTitle("Report") return tmpl.Node(t, "report", data), nil } ``` Layouts, title and errors work the same. Escaping is the contextual escaping of `html/template` itself. ## Challenge Write a component `Seats(n int) h.Node` that shows "sold out" in italics when `n == 0`, "1 seat" in the singular and "N seats" in the plural, and use it in the events list. :::solution ```go func Seats(n int) h.Node { switch { case n == 0: return h.Em(h.Text("sold out")) case n == 1: return h.Text("1 seat") default: return h.Textf("%d seats", n) } } ``` ::: --- # Forms Source: /trilha/learn/forms POST in the same page.go, automatic CSRF protection and the redirect-after-write pattern. A page can receive forms by exporting `POST` (or `PUT`, `PATCH`, `DELETE`) next to `Page`. Trilha verifies the CSRF token before calling your function. ## The page with the form `app/events/new/page.go`: ```go package new import ( "strings" "github.com/emersonjoe/trilha" "github.com/emersonjoe/trilha/h" "agenda/internal/events" ) func Page(c *trilha.Ctx) (h.Node, error) { c.SetTitle("New event") msg := c.Query("error") return h.Fragment( h.H1(h.Text("New event")), h.If(msg != "", h.P(h.Class("error"), h.Text(msg))), h.Form(h.Method("post"), h.Action("/events/new"), trilha.CSRFInput(c), h.Label(h.For("name"), h.Text("Name")), h.Input(h.ID("name"), h.Name("name"), h.Required(), h.Autofocus()), h.Label(h.For("city"), h.Text("City")), h.Input(h.ID("city"), h.Name("city")), h.Button(h.Type("submit"), h.Text("Publish")), ), ), nil } func POST(c *trilha.Ctx) error { if err := c.FormErr(); err != nil { return err // 400 on an invalid form, 413 if it exceeded the limit } name := strings.TrimSpace(c.Form("name")) if name == "" { return c.Redirect("/events/new?error=Enter+a+name") } ev := events.Create(name, c.Form("city")) return c.Redirect("/events/" + ev.Slug) } ``` @demo form ## What happens on submit 1. The browser sends `POST /events/new` with the fields and the `_csrf`. 2. Trilha compares `_csrf` with the `trilha_csrf` cookie (constant time). Different or missing: **403**, and `POST` does not even run. 3. `POST` runs and returns `c.Redirect(...)`: a **303 See Other** response. The browser does a `GET` on the new URL. Reloading the page does not resubmit the form. `trilha.CSRFInput(c)` creates the cookie on the first render and the hidden field. JavaScript clients may send the same value in the `X-CSRF-Token` header. ## Validation and messages The example above checks the name by hand and answers through the query string, which keeps the POST → redirect → GET pattern and works without JavaScript. As soon as a form has more than a couple of fields, put the rules on the struct instead: the `validate` tag sits next to the field it talks about, and `Bind` applies every rule before returning. ```go type entry struct { Name string `form:"name" validate:"required,min=3,max=80"` Email string `form:"email" validate:"required,email"` Seats int `form:"seats" validate:"min=1,max=10"` } func POST(c *trilha.Ctx) error { var in entry if err := c.Bind(&in); err != nil { if errs, ok := err.(trilha.FieldErrors); ok { // Same page, 422, values kept, one message per field. return c.Render(http.StatusUnprocessableEntity, form(c, in, errs)) } return err } ev := events.Create(in.Name, in.Email, in.Seats) return c.Redirect("/events/" + ev.Slug) } ``` `FieldErrors` is a `map[string]string` (field → message), so the form reads it straight: `ui.Errors(errs, "email")` prints the message and `ui.InvalidIf(errs, "email")` marks the input with `aria-invalid`. Nothing short-circuits — the person sees every mistake at once, not one per submit. The rules are `required`, `min`, `max`, `len`, `email`, `url`, `oneof` and `eqfield`; the [validation reference](/trilha/reference/validation) has what each one means per type. Two of them are worth spelling out here: - **Every rule but `required` ignores an empty value.** An optional field with `min=3` only answers for what somebody typed. - **`required` means "not the zero value".** Where `0` or `false` is a real answer, declare the field as a pointer (`*int`): absent stays absent, and zero arrives as zero. Messages come in English. An app that speaks another language calls `trilha.UseValidationPTBR()` in `Setup`, or writes its own into `trilha.ValidationMessages`. ### When the tag is not enough A rule about the shape of a value belongs to the type, and then every form that uses the type is covered: ```go type Money string func (m Money) Validate() error { if v, err := ParseMoney(string(m)); err != nil || v <= 0 { return errors.New("must be greater than zero") } return nil } ``` A rule that reads two fields belongs to the struct: give it a `Validate() error` and it runs at the end, only when no field failed. A rule you repeat across projects becomes a tag of your own: ```go trilha.AddRule("cep", func(f trilha.Field) bool { return validZIP(f.Text) }) trilha.ValidationMessages["cep"] = "invalid ZIP code" ``` Here is where this stops: the tag says what a **value** accepts, not what the **system** accepts. "This account exists" and "this room is free that night" are questions for your data, and they stay in your package. Run them after `Bind` and merge the result into the same `FieldErrors`, so both kinds of message reach the person in the same response. ## Methods the browser does not send HTML forms only send GET and POST. For "delete", export `DELETE` for API clients and make the page's `POST` call the same logic: ```go func DELETE(c *trilha.Ctx) error { if !events.Delete(c.Param("slug")) { return trilha.ErrNotFound } return c.Redirect("/events") } func POST(c *trilha.Ctx) error { return DELETE(c) } ``` ## Limits The request body is limited to 1 MiB by default (`Config.MaxBodyBytes`). Above that the response is 413 before your code runs. ## Challenge Add a numeric `seats` field to the form, accept only 1 to 10, and show the message next to the field instead of on the next page. :::solution ```go type entry struct { Name string `form:"name" validate:"required,min=3"` City string `form:"city"` Seats int `form:"seats" validate:"required,min=1,max=10"` } // In POST, c.Bind(&in) returns trilha.FieldErrors, and the page renders again // with c.Render(http.StatusUnprocessableEntity, ...) and ui.Errors(errs, "seats"). ``` ::: --- # API routes Source: /trilha/learn/api route.go with one function per HTTP method, JSON in and out, and errors with status codes. A folder with `route.go` answers JSON instead of HTML. Each HTTP method is an exported function with the usual shape: `func(c *trilha.Ctx) error`. ## List and create `app/api/events/route.go`: ```go package events import ( "net/http" "strings" "github.com/emersonjoe/trilha" "agenda/internal/events" ) func GET(c *trilha.Ctx) error { return c.JSON(http.StatusOK, events.All()) } func POST(c *trilha.Ctx) error { var in struct { Name string `json:"name"` City string `json:"city"` } if err := c.BindJSON(&in); err != nil { return err // 400 on invalid JSON, 413 above the limit } if strings.TrimSpace(in.Name) == "" { return trilha.Errorf(http.StatusUnprocessableEntity, "name is required") } ev := events.Create(in.Name, in.City) c.Header("Location", "/api/events/"+ev.Slug) return c.JSON(http.StatusCreated, ev) } ``` ```bash curl -s localhost:3000/api/events curl -s -X POST localhost:3000/api/events -d '{"name":"HTTP Workshop","city":"Recife"}' curl -s -X PUT localhost:3000/api/events # 405 with Allow: GET, POST ``` ## One resource per slug `app/api/events/slug_/route.go` answers `/api/events/{slug}`: ```go func GET(c *trilha.Ctx) error { ev, ok := events.Find(c.Param("slug")) if !ok { return trilha.ErrNotFound // 404 problem+json } return c.JSON(200, ev) } func DELETE(c *trilha.Ctx) error { if !events.Delete(c.Param("slug")) { return trilha.ErrNotFound } c.Writer().WriteHeader(http.StatusNoContent) return nil } ``` ## Errors become status codes | You return | Response | |---|---| | `nil` | whatever you wrote; 204 if you wrote nothing | | `trilha.ErrNotFound` | 404 as JSON | | `trilha.Errorf(422, "msg")` | 422 with `"detail":"msg"` | | `c.Redirect(url)` | 303 | | any other `error` | 500 with `"title":"Internal Server Error"`; the real message goes to the log | | `&trilha.Problem{…}` | exactly the problem you described | The body is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details, sent as `application/problem+json` — the format generated clients, gateways and contract tests already read: ```json {"type":"about:blank","title":"Not Found","status":404, "instance":"/api/events/nope","request_id":"01J…"} ``` `fields` is still there on a 422, unchanged, so the form that reads it keeps working. When a status is not enough, describe the problem yourself: ```go return &trilha.Problem{ Type: "https://example.com/probs/sold-out", Title: "Sold out", Status: http.StatusConflict, Detail: "The last seat went 4 minutes ago.", Extra: map[string]any{"waitlist": "/api/events/" + ev.Slug + "/waitlist"}, } ``` Which format comes out follows the kind of route, with `Accept` as the tie-breaker: a `route.go` answers `problem+json`, unless the client prefers `text/html` — a browser in the address bar gets the error page, wherever the route lives. See [Errors](/trilha/reference/errors). ## The OpenAPI document `trilha openapi` writes the OpenAPI 3.1 document of your API routes. There is nothing to annotate and keep in sync: the source of the document is the code that answers the request. ```bash trilha openapi # writes openapi.json trilha openapi -o - | jq .paths # to stdout trilha openapi --check # in the CI: fails when the file drifted from the code ``` What it reads by itself: | In the code | In the document | |---|---| | the folder under `app/api/` | the path, with `id_` as a path parameter | | exported `GET`, `POST`, `PUT`, `PATCH`, `DELETE` | one operation each | | the doc comment | `summary` (first sentence) and `description` | | `c.Bind(&in)` / `c.BindJSON(&in)` | `requestBody` with the schema of `in`, plus a 422 | | `c.JSON(status, v)` | that status with the schema of `v` | | `c.Writer().WriteHeader(204)` | that status with no body | | `c.Header("Content-Type", …)` | the media type of the response | | `trilha.ErrNotFound`, `trilha.Errorf(status, …)`, `&trilha.Problem{Status: …}` | that status as `problem+json` | | `json` and `validate` tags | property names, `required`, `maxLength`, `enum`, `format` | The schema comes out of the same `validate` tag `Bind` reads, so the document cannot promise something the validation refuses. Every operation also carries the `default` response with the [`Problem`](/trilha/reference/errors) schema: since 0.21.0 that is the shape of every API error. Only `route.go` routes are described. A page answers HTML to a browser; there is no contract there for a client to hold you to. ### When the deduction does not reach A middleware, a `c.Query` or a folder with a dot in its name are outside what reading the handler can tell. Write it in the doc comment: ```go // GET writes the month as CSV. // // openapi:query mes string month to export, AAAA-MM (default: the current one) // openapi:response 429 // openapi:tag report func GET(c *trilha.Ctx) error { … } ``` | Directive | What it does | |---|---| | `openapi:response <status> [type]` | adds the response; without a type, `problem+json` | | `openapi:body <type>` | the request body, when it is not a `Bind` | | `openapi:query <name> <type> [description]` | a query parameter | | `openapi:tag <name>` | the tag of the operation (default: the last fixed path segment) | A type nobody declares is an error naming the file and the handler, not an empty schema published as if it were right. ## CSRF in APIs By default `route.go` does **not** require a CSRF token: APIs are usually called with a session token or a bearer token, and the `SameSite=Lax` cookie already blocks automatic submission by the browser. If your API is called by the site itself with cookies, turn on `Config.CSRFForAPI` and send `X-CSRF-Token`. ## Challenge Add `PATCH` to `/api/events/{slug}` that updates only the fields present in the JSON and answers 200 with the new event. JSON with unknown fields must return 400. :::solution `c.BindJSON` already rejects unknown fields. For "only the fields present", use pointers: ```go func PATCH(c *trilha.Ctx) error { var in struct { Name *string `json:"name"` City *string `json:"city"` } if err := c.BindJSON(&in); err != nil { return err } ev, ok := events.Find(c.Param("slug")) if !ok { return trilha.ErrNotFound } if in.Name != nil { ev.Name = *in.Name } if in.City != nil { ev.City = *in.City } events.Save(ev) return c.JSON(200, ev) } ``` ::: --- # Data and cache Source: /trilha/learn/data Where the data comes from, how long an answer is worth keeping, and what knocks it down — with cache.Do, tags and cache.Once. Trilha has no ORM, no repository and no opinion about your database: a page calls your code, your code calls whatever you use. What the framework does bring is the part that is always written by hand and always written wrong — keeping an answer for a while, and throwing it away when it stops being true. ```go import "github.com/emersonjoe/trilha/cache" ``` ## The cache is yours, not the framework's There is no `app.Cache()`. You create it, you say how big it gets, and you keep it where the code that fills it lives — usually the package that queries the database: ```go // internal/events/events.go var Cache *cache.Cache // app/setup.go func Setup(a *trilha.App) error { events.Cache = cache.New(cache.Options{ Name: "events", MaxEntries: 500, Metrics: a.Metrics(), }) return nil } ``` `MaxEntries` has a default (10 000) and no way to say "no limit". A cache without a ceiling is a memory leak that takes a week to show up: every key someone can invent — a search term, a filter in the query string — becomes an entry that never leaves. When the ceiling is reached the least recently used entry is evicted. ## `Do`: the value, or the way to get it `cache.Do` is the whole package in one call. It returns what is stored, or runs your function and stores what it returns: ```go func Upcoming(ctx context.Context) ([]Event, error) { return cache.Do(ctx, Cache, cache.Key{ Name: "events:upcoming", TTL: 5 * time.Minute, Tags: []string{"events"}, }, func(ctx context.Context) ([]Event, error) { return db.Upcoming(ctx) }) } ``` | `Key` field | What it is | |---|---| | `Name` | the address of the value; equal names are the same entry | | `TTL` | how long it is worth; `0` (or less) means no expiry | | `Tags` | labels for invalidating in bulk later | The name is a decision, not a detail: everything that changes the answer belongs in it. A list that depends on the page and the logged-in user is `posts:page:2:user:42`, not `posts` — a cache key that forgets the user is how one person's data is served to another. An error is returned to the caller and cached for nobody. The next request tries again. ### One fetch at a time The moment a hot key expires, every request that wanted it arrives at the same instant and every one of them goes to the database. `Do` does not let that happen: the first caller runs the function, the others wait for it and read the same answer. It is one fetch per key, however many requests are queued behind it. ## What knocks it down Time is the weak way to invalidate — five minutes of a wrong list is five minutes of someone reading a post that was already deleted. The strong way is to say so: ```go func Create(ctx context.Context, e Event) error { if err := db.Insert(ctx, e); err != nil { return err } Cache.Invalidate("events") return nil } ``` `Invalidate` drops every entry carrying that tag, whatever its name, and returns how many it dropped. `Delete(names...)` drops by name, and `Clear()` empties everything. Put the call next to the write, never next to the read. A cache is invalidated by whoever changed the data — the code doing the reading has no way of knowing that something changed. ## `Once` is not the cache The layout wants to know who is logged in. The header wants the same. Two components inside the page want it too. `cache.Once` answers the question once per request: ```go func Layout(c *trilha.Ctx, children h.Node) (h.Node, error) { user, err := cache.Once(c, "user", func() (*users.User, error) { return users.Find(c.Context(), auth.From(c).Subject) }) … } ``` Nothing stored here survives the response, and that is the whole point. It takes no `*Cache`, no TTL and no tag, because there is nothing to expire: the value dies with the request that created it. Reach for `Once` when the alternative is threading a value through six function signatures, and for `Do` when the answer is the same for everyone. Do not swap them. A user's name in `Do` under the name `"user"` is that user's name served to the next person who opens the page. ## Seeing it work With `Options.Metrics`, four series appear in `/metrics`, labelled by the cache's name: ``` trilha_cache_hits_total{cache="events"} 1043 trilha_cache_misses_total{cache="events"} 61 trilha_cache_evictions_total{cache="events"} 0 trilha_cache_entries{cache="events"} 61 ``` Hits over hits plus misses is the hit ratio — under 50 % the TTL is too short or the key carries something it should not. Evictions climbing means the ceiling is too low: the cache is throwing away what it was about to be asked for. ## The cache the browser keeps The cache above saves the server a trip to the database. This one saves the network a whole response: the browser already has the page and asks only whether it changed. ```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.Updated.UTC().Format(time.RFC3339Nano)) { return nil, nil // the copy in the browser is current: 304, no body } c.SetTitle(p.Title) return view(p), nil } ``` `ETag` writes the tag and reports whether the request already carried it. When it says yes the `304` is already written, so return `nil, nil` — a body there would be thrown away. `LastModified` is the same deal for a date, and `CacheControl` writes the header as you typed it. `no-cache` does not mean "do not store"; it means "store it, but ask me before reusing it", which is exactly what makes the `304` happen. The tag is a version of the data, not a hash of the page — and Trilha will not compute one for you. Every response carries a fresh CSP nonce, so a hash of the HTML would never match twice. Anything that moves when the data moves works: `updated_at`, a revision number, the ids of what was rendered. > A tag that forgets who is reading is the same bug as a cache key that forgets the user. If the > page changes with the visitor, put that in the tag or do not send one. Files under `static/` already do this on their own: the fingerprint in `?v=` is their ETag, so the second visit costs a `304` and no bytes. ## Challenge The event detail page calls the database on every visit. Cache it for an hour, with a tag that lets `Save` drop just that one event and another that drops the whole section. :::solution Tags are a list, so an entry can belong to more than one group: ```go func Find(ctx context.Context, slug string) (Event, error) { return cache.Do(ctx, Cache, cache.Key{ Name: "event:" + slug, TTL: time.Hour, Tags: []string{"events", "event:" + slug}, }, func(ctx context.Context) (Event, error) { return db.Find(ctx, slug) }) } func Save(ctx context.Context, e Event) error { if err := db.Save(ctx, e); err != nil { return err } // The event changed: its own page, and every list it appears in. Cache.Invalidate("event:"+e.Slug, "events") return nil } ``` ::: --- # Middleware Source: /trilha/learn/middleware Intercept a subtree of routes, pass values to pages and protect areas. A `middleware.go` runs before any route in its folder and in the folders below. The one at the root runs on every request; the one in a group, only on the group's routes. ## The signature ```go func Middleware(c *trilha.Ctx, next trilha.Next) error ``` Call `next()` to continue. Do not call it to stop. Return an error so the default handling answers (redirect, 404, 500). ## Timing every route `app/middleware.go`: ```go package app import ( "time" "github.com/emersonjoe/trilha" ) func Middleware(c *trilha.Ctx, next trilha.Next) error { start := time.Now() err := next() c.Header("Server-Timing", "app;dur="+time.Since(start).String()) return err } ``` The header is written after `next()` but before the response is sent, because pages are rendered in memory and written at once. ## Protecting the organizer's area A route group is the natural place to require login without polluting the URL: ```text app/organizer-/middleware.go app/organizer-/dashboard/page.go → /dashboard app/organizer-/report/page.go → /report ``` ```go package organizer import "github.com/emersonjoe/trilha" func Middleware(c *trilha.Ctx, next trilha.Next) error { ck, err := c.Cookie("session") if err != nil || !session.Valid(ck.Value) { return trilha.RedirectCode("/login?next="+c.Request().URL.Path, 302) } c.Set("user", session.User(ck.Value)) return next() } ``` In the page, `c.Get("user")` returns the value. Values live only during the request. ## A rule for one method A folder often serves two roles: a `GET` anyone in the area may read, and a `POST` only an editor may send. Putting the permission in the first line of the handler works — until the eleventh route, where someone forgets it. `middleware.go` takes the method in the name: ```go package organizer import ( "net/http" "github.com/emersonjoe/trilha" ) // Everybody who got here may read. func Middleware(c *trilha.Ctx, next trilha.Next) error { c.Set("area", "organizer") return next() } // Only an editor may write, in this folder and below it. func MiddlewarePOST(c *trilha.Ctx, next trilha.Next) error { if c.Get("role") != "editor" { return trilha.Errorf(http.StatusForbidden, "only editors may change the goal") } return next() } ``` `MiddlewareGET`, `MiddlewarePOST`, `MiddlewarePUT`, `MiddlewarePATCH` and `MiddlewareDELETE` are recognised. They inherit down the subtree exactly like `Middleware`, and they run inside it — the route decides first, then the method refines. A `MiddlewareX` that reaches no route serving `X` is a generation error (`E_UNUSED_METHOD_MIDDLEWARE`): a permission that guards nothing is the failure this convention exists to prevent. The 403 above renders through `app/error.go`, with the app's layout; see [Errors](/trilha/reference/errors). ## Order For `GET /dashboard`: ```text middleware(app) → middleware(app/organizer-) → middlewareGET(app) → middlewareGET(app/organizer-) → Page → layouts ``` Outside in, route-wide chain before the method's own. If a middleware does not call `next()`, the inner ones and the page do not run, but the outer ones finish normally (the timing one above still writes its header). ## Short-circuit with your own response A middleware may answer directly and return `nil`: ```go if c.Request().Header.Get("X-Maintenance") == "1" { return c.Text(503, "under maintenance") } ``` Since the response has already started, Trilha does not try to write another one. ## Challenge Create `app/api/middleware.go` requiring the `Authorization: Bearer <key>` header across the whole API and answering 401 as JSON when it is missing, without affecting the HTML pages. :::solution ```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 ") || !keys.Valid(strings.TrimPrefix(auth, "Bearer ")) { return trilha.Errorf(http.StatusUnauthorized, "invalid key") } return next() } ``` Because the folder is `app/api/`, only API routes go through it, and the error comes out as JSON because the route is a `route.go`. ::: --- # Security Source: /trilha/learn/security What Trilha protects by default, how to adjust it, and what remains your responsibility. Trilha follows two references: the **NIST Cybersecurity Framework 2.0** (the Identify, Protect, Detect, Respond, Recover and Govern functions) and **OWASP ASVS 4.0** level 2. A web framework can only *protect* and *detect*; the rest is the work of whoever operates the app, and this chapter says exactly where one ends and the other begins. ## What comes turned on | Control | Default | NIST CSF 2.0 | OWASP ASVS | |---|---|---|---| | HTML escaping (`h`) and contextual escaping (`tmpl`) | always | PR.DS | V5.3 | | `Content-Security-Policy` with a per-request nonce | on | PR.PS | V14.4 | | `Strict-Transport-Security` | on over HTTPS | PR.DS | V9.1 | | `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy` | on | PR.PS | V14.4 | | CSRF by *double-submit cookie* on forms | on | PR.AA | V4.2 | | Request body limit (1 MiB) | on | PR.IR | V13.1 | | Read, write and idle timeouts; header limit | on | PR.IR | V13.1 | | Static files restricted to `public/` | always | PR.DS | V12.3 | | Opaque errors in production; no stack, no paths | on | PR.DS | V7.4 | | Structured logs without body or cookies, with `request_id` | always | DE.CM | V7.1 | | Security events (CSRF, 401/403, 413, 429, panic) in the log | always | DE.AE | V7.2 | | Signed cookies (`SetSigned`/`Signed`) | with `TRILHA_SECRET` | PR.AA | V3.4 | | Per-client rate limit | optional | PR.IR | V11.1 | | Trusted proxies (`X-Forwarded-*`) | optional | PR.AA | V14.1 | ## CSP and inline scripts The default policy only allows scripts from the site itself or carrying the request's **nonce**. An inline `<script>` needs it: ```go h.Script(trilha.NonceAttr(c), h.Raw(`document.body.dataset.ready = "1"`)) ``` The reload script of `trilha dev` already uses the nonce. To allow an external origin (fonts, an image CDN) without rewriting the policy, add to `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 } ``` For a policy entirely your own, set `a.Security().CSP` (the string may contain `{nonce}`); to turn a header off, assign `trilha.Off`. ## Behind a proxy If the app runs behind nginx, Caddy or a load balancer, `RemoteAddr` is the proxy. Tell Trilha whom to trust so that `X-Forwarded-For` and `X-Forwarded-Proto` count: ```bash TRILHA_TRUSTED_PROXIES=10.0.0.0/8,127.0.0.1 ``` Only then does `c.ClientIP()` return the real client, HSTS is sent and the rate limit counts per client instead of per proxy. Without that variable, `X-Forwarded-*` headers are ignored, which is the safe behavior. ## The host you answer for The `Host` header is chosen by whoever calls. Your app uses it to build absolute URLs — the password-reset link, the invitation e-mail, a redirect — and any cache in front keys on it. A request with `Host: attacker.example` is enough to get a link pointing at somebody else's domain into an e-mail your app sends. List the hosts you answer for and the rest gets 400 before the router runs: ```go trilha.Config{AllowedHosts: []string{"example.com", "*.example.com"}} ``` ```bash TRILHA_ALLOWED_HOSTS=example.com,*.example.com ``` The port and the case do not count, so `example.com:8443` passes. `*.example.com` allows one extra label — `app.example.com` yes, `a.b.example.com` no. In `Dev`, `localhost` and the loopback addresses always pass, so copying the production list into your dev config does not break the dev server. An empty list checks nothing, which is what an app that never set it gets. A refusal is a `host` security event, so it shows up in the log, in the metric and in `OnSecurityEvent` like every other block. :::note The list is about the host the **app** receives. If a proxy rewrites `Host`, write down what the proxy sends, not what the browser typed. ::: ## Session with a signed cookie A signed cookie cannot be forged or altered, and it expires on its own: ```go // in the login POST if err := c.SetSigned("session", user.ID, 8*time.Hour); err != nil { return err } // in the middleware of the restricted area id, ok := c.Signed("session") if !ok { return trilha.RedirectCode("/login", 302) } ``` The key comes from `TRILHA_SECRET` (32 bytes or more; `openssl rand -base64 32`). In development, `trilha dev` generates an ephemeral key per session. In production without the variable, `SetSigned` returns an error and logs a warning naming the cookie and the route — once per cookie, and only for an app that actually signs one: a warning in every boot of an app with its own session is what teaches a team to stop reading warnings. To rotate the key without dropping sessions, put the old one in `TRILHA_SECRET_PREVIOUS` until they expire. :::warning A signed cookie guarantees integrity, not secrecy: the value is readable by whoever holds the cookie. Store an identifier in it, never sensitive data. ::: ## Rate limiting Globally, in `app/setup.go`, or per subtree, in a `middleware.go`: ```go // app/api/middleware.go var limit = trilha.Limit(5, 20) // 5 req/s per client, burst of 20 func Middleware(c *trilha.Ctx, next trilha.Next) error { return limit(c, next) } ``` The response is 429 with `Retry-After`. The counter lives in the process memory: with several replicas, each one counts its own share. ## Detect and respond Every block produces a `security` line in the log, with `kind`, `ip`, `path` and `request_id`, and calls `Config.OnSecurityEvent` if you set one. That is the hook to count attempts, alert or block an IP at the firewall. Before publishing, run: ```bash trilha audit ``` It checks `TRILHA_SECRET`, proxies, `trilha_gen.go`, the Go version, `go vet` and `govulncheck`, and exits with an error when there is a critical item. ## Files that arrive from outside An upload is the one request where the app writes what somebody else sent, under a name somebody else chose. `c.File` answers with the file only after the three checks that matter: ```go func POST(c *trilha.Ctx) error { c.AllowBody(8 << 20) // the request; the file limit below is another thing up, err := c.File("file", 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, page(c, errs)) } return err } defer up.Close() path, err := up.Save("uploads") // never leaves "uploads" ... } ``` - **Size**: `MaxSize` is per file, apart from `Config.MaxBodyBytes`. A route that accepts a 4 MB file still needs to let a slightly larger body through (`c.AllowBody`), because the body carries the other fields too. - **Type**: `Accept` is matched against the type detected in the first 512 bytes of the content, never the extension and never the `Content-Type` the client announced — a PDF renamed to `photo.png` is a PDF. `up.MIME` is what it really is and `up.Ext` is the extension that matches. Careful: the standard library detects what it knows; formats that are a zip inside (`.docx`, `.xlsx`) come back as `application/zip`, and a CSV as `text/plain`. Where the difference matters, look at the content yourself. - **Name**: `up.Name` has no directory, no separator of either platform, no control character, at most 100 characters, and is never empty or `..`. `up.Save(dir)` writes inside `dir` with mode 0600 and a free name (`note.pdf`, then `note-1.pdf`), so a second upload never eats the first. A rule that fails is `FieldErrors` under the field's name — the same answer `Bind` gives, so the form shows the message where the person is looking instead of the app answering 500. Two things stay yours: **where** the file goes (a directory outside the code, a bucket, a database) and **who** may send it. And a file the app serves back is served from a route of yours, with the type you decided — never by handing the upload directory to `http.FileServer`. ## Another origin calling your app The browser only lets a script read a response from another origin when the server says so. Say it in one place, `Config.CORS`, with the list of origins spelled out: ```go func Config(cfg *trilha.Config) error { cfg.CORS = trilha.CORS{ Origins: []string{"https://panel.example.com"}, Methods: []string{"GET", "POST", "DELETE"}, MaxAge: 10 * time.Minute, } return nil } ``` With that, the `OPTIONS` preflight is answered by the framework, before the router — so it works on every route, including static files — and every response to an allowed origin carries `Access-Control-Allow-Origin` and `Vary: Origin`. Three things the hand-written middleware usually gets wrong, and that this one does not: - **`"*"` with credentials is refused at boot, not at runtime.** `Origins: []string{"*"}` serves a public API; the moment you set `Credentials: true` next to it, `New` panics. The usual "fix" for that pair — echoing back whatever `Origin` arrives — hands your users' session to any site that asks. - **The origin list is exact.** No wildcard subdomains: `https://app.example.com` is one entry, and `example.com.attacker.net` never matches by accident. - **`Vary: Origin` always goes out**, so a cache in front of the app never serves the allowed origin's response to somebody else. A preflight from an origin nobody listed gets 403 — the browser is asking, and a plain answer is what shows up in the network tab. A **simple** request from an unlisted origin is served as usual, only without the CORS headers: it is the browser that hides the response from the script, and a client that is not a browser was never the one being protected here. ### When only a few paths are public `Config.CORS` is the app. A discovery document under `/.well-known/`, fetched from another origin by a client that has no session yet, is three paths out of ninety — and opening the other eighty-seven to fix three trades a gap for a surface. Those routes carry their own policy, in the `route.go` that serves them: ```go var CORS = trilha.CORS{Origins: []string{"*"}, Methods: []string{"GET"}} ``` The route answers its own preflight, with the same checks and the same 403; the rest of the app stays same-origin. A route that declares a policy decides alone — the app-wide list does not narrow it, and it does not widen the app-wide list for anybody else. See [Conventions](/trilha/reference/conventions). ## What remains yours - **Authentication and authorization**: who the user is and what they may do. Trilha gives you the signed cookie and the middleware; the business rule is yours. - **Data at rest**: database encryption, backups, retention. - **TLS**: terminate at the proxy or use a certificate in your own `http.Server` through `a.Handler()`. - **Secrets**: only in environment variables or a vault; never in the repository. - **Govern, Identify, Recover**: inventory, data classification, response plan and restoration are processes of the organization. The project's `SECURITY.md` describes how to report vulnerabilities in the framework, and [SECURITY-MODEL.md](https://github.com/emersonjoe/trilha/blob/main/SECURITY-MODEL.md) is the written threat model: what each control defends against, and what stays open. ## Challenge Make the `/dashboard` area of your app require a signed session, with a limit of 10 attempts per minute on the login form, and count in `OnSecurityEvent` how many blocks happened. :::solution ```go // app/login/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 blocks atomic.Int64 func Setup(a *trilha.App) error { a.Config().OnSecurityEvent = func(e trilha.SecurityEvent) { if e.Kind == "rate" { blocks.Add(1) } } return nil } ``` `a.Config()` gives access to the configuration inside `Setup`; the login limiter uses `trilha.Limit` with 10 tokens per minute. ::: --- # Health and observability Source: /trilha/learn/observability Liveness and readiness probes, metrics in the Prometheus format and log correlation, taking care not to turn monitoring into a leak. An app in production has to answer three questions for whoever operates it: *is it up?*, *can it take traffic?* and *what is going on?*. Trilha answers all three with no dependency, and answers in a way that does not hand the map of your infrastructure to anyone passing by. The reference here is twofold, as in the security chapter: **NIST SP 800-53r5** (AU-2 and AU-3 for the content of the record, AU-9 to protect that information, SI-4 for monitoring and SC-5 against denial of service) and **OWASP** (Top 10 2021 A09, API Security 2023 API8 and chapter V7 of the ASVS). ## The two probes Without configuring anything, every Trilha app already answers: | Address | Question | Runs checks? | |---|---|---| | `/_trilha/health/live` | can the process serve? | no | | `/_trilha/health/ready` | can it take traffic? | yes | | `/_trilha/health` | same as `ready` | yes | The split is not bureaucracy. In Kubernetes, a failing *readiness* takes the pod out of the load balancer; a failing *liveness* **kills the process**. If both ran the same database check, a network blip would restart the whole fleet instead of waiting for the database to come back. That is why `live` never touches a dependency. ```go // app/setup.go func Setup(a *trilha.App) error { a.Check("db", func(ctx context.Context) error { return db.PingContext(ctx) }) a.Check("queue", func(ctx context.Context) error { return queue.Ping(ctx) }) return nil } ``` Each check runs with a deadline (2 s by default) and in parallel; a `panic` inside it becomes a failure and does not bring the process down. The result is cached for 1 s: one probe per second — or ten thousand per second, coming from someone with bad intentions — does not become ten thousand `SELECT 1` on your database. ## What an anonymous caller sees In production, without authorization, the response is exactly this: ```json {"status":"fail"} ``` Check name, error message, hostname and version are left out on purpose (ASVS V7.4.1). Knowing there is a Postgres called `finance` and that it is down is half the way for someone probing the target. The cause goes to the log, where access control already exists, and to whoever authenticates: ``` curl -H "Authorization: Bearer $TRILHA_OBS_TOKEN" https://app/_trilha/health ``` ```json {"status":"fail","checks":[{"name":"db","status":"fail","duration_ms":2001.4, "error":"deadline exceeded: context deadline exceeded"}],"uptime_seconds":8134.2} ``` In `dev` the details are open — there, the target is you. ## Metrics The metrics endpoint **does not exist** until you ask for it. That is deliberate: a public `/metrics` is the misconfiguration described in OWASP's API8, and it tells the visitor how many routes you have, which ones error and at what time your traffic drops. ```go func Config(cfg *trilha.Config) { cfg.Observability.Metrics = "/_trilha/metrics" // or TRILHA_METRICS // TRILHA_OBS_TOKEN (32+ bytes) authorizes the scrape; // alternative: Trusted with the collector's CIDR. cfg.Observability.Trusted = []string{"10.42.0.0/16"} } ``` The output is the Prometheus text format, so Prometheus, VictoriaMetrics, Grafana Alloy and the OpenTelemetry Collector read it without a translator: ``` 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 ``` Look at the `route` label: it is the **registered pattern**, `/blog/{slug}`, never the concrete path `/blog/how-i-did-x`. A concrete path is user input — it carries identifiers, sometimes a token in the query string, and makes the number of series grow without bound until memory runs out. Whatever does not match a registered route (static files, 404) falls into a single `other` label, and every metric has a series cap (a thousand by default). Your own metrics go in the same place: ```go posts.Published = a.Metrics().Counter("blog_posts_total", "Published posts.") slow := a.Metrics().Histogram("blog_render_seconds", "Render time.", nil, "template") slow.With("post").Observe(dur.Seconds()) ``` ## Finding a request in the log Every request log already carries `request_id`, and the same value comes back in the `X-Request-ID` header. When the client sends `traceparent` (W3C Trace Context — what a gateway, an Istio or an OpenTelemetry SDK sends), `trace_id` comes along: ```go func GET(c *trilha.Ctx) error { c.Log().Info("querying supplier", "tax_id", taxID) // request_id + trace_id return c.JSON(200, resp) } ``` Trilha **propagates** the context and puts it in the log; it does not export spans or sample traces. Full distributed tracing is a collector's job, and bolting it onto the core would cost dozens of dependencies. What never goes in the log, by design: request body, cookies, the `Authorization` header and the query string (ASVS V7.1.1). That is where secrets travel. ## Cost With the metrics endpoint off, the instrumentation does not run: it is a pointer comparison. On, it costs **zero allocations** per request (two map lookups with a key built on the stack and a few atomic increments); the time difference stays within the noise of the reference machine. The numbers are in [Performance](/trilha/reference/performance). ## Challenge Make your app's `/_trilha/health/ready` check the database **and** an external service, with a 500 ms deadline for the external one; expose the metrics only to the `10.0.0.0/8` network; and count, in a metric of your own, how many times the external service failed. :::solution ```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 { failures := a.Metrics().Counter("integration_failures_total", "Failed calls to the partner.", "service") a.Check("db", func(ctx context.Context) error { return db.PingContext(ctx) }) a.Check("partner", func(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) defer cancel() if err := partner.Ping(ctx); err != nil { failures.With("partner").Inc() return err } return nil }) return nil } ``` The partner's short deadline coexists with the general one (`Observability.Timeout`): whichever expires first wins. And because the counter is created in `Setup`, it shows up in the scrape from the first request, with value zero — which beats vanishing from the dashboard until the first failure. ::: --- # Authentication with Entra ID, Keycloak, Cognito and Clerk Source: /trilha/learn/authentication OpenID Connect login with PKCE, signed session, roles and federated logout, with no external dependency and no password in your database. Almost every internal app reaches the same point: someone asks "can I sign in with my company account?". The answer is OpenID Connect — Entra ID (formerly Azure AD) and Keycloak speak the same protocol, and the `auth` package implements the app side with the standard library. The advantage is not only convenience. A password you do not store is a password you do not leak; MFA, lockout after failed attempts and rotation policy become the provider's problem, and they have a team for that. What is left for the app is the part nobody can outsource: validating the token properly and keeping the session in order. ## The flow, in three routes The authorization code flow with PKCE has four steps: the app sends the browser to the provider, the person authenticates there, the provider returns a code to a route of yours, and the app exchanges that code for an `id_token` — this last exchange happens server to server, it never goes through the browser. In `app/`, that is three files: ```go // app/login/route.go var Kind = trilha.KindPage func GET(c *trilha.Ctx) error { return sso.Start(c) } // app/login/callback/route.go var Kind = trilha.KindPage func GET(c *trilha.Ctx) error { return sso.Callback(c) } // app/logout/route.go var Kind = trilha.KindPage func POST(c *trilha.Ctx) error { return sso.Logout(c) } ``` `sso` here is a package of yours, some 30 lines, that reads the environment and holds the `*auth.Auth` (see `examples/sso/internal/sso`). `auth` registers no route: `app/` decides the addresses, as everywhere else in the framework. ## Configuring the provider ```go p := auth.EntraID(os.Getenv("SSO_TENANT"), id, secret, "https://app.example/login/callback") // or p := auth.Keycloak("https://kc.example", "production", id, secret, redirect) // or p := auth.Cognito("us-east-1", "us-east-1_ABC123", id, secret, redirect) // or p := auth.Clerk("verb-noun-00.clerk.accounts.dev", id, secret, redirect) // or any conforming provider, by issuer: p := auth.OIDC("https://accounts.example/", id, secret, redirect) flow := auth.New(p, auth.Options{LoginPath: "/login", AfterLogin: "/dashboard"}) ``` Nothing there makes a network call: discovery (`/.well-known/openid-configuration`) happens on the first login and is cached for one hour. A provider that is down does not stop the app from starting — it only stops people from signing in, which is the honest behavior. The client secret **never** goes in the code. `trilha audit` complains if it finds a literal in that position, and a secret that made it into git must be rotated at the provider, not merely removed from the file. ## Cognito and the logout that is not standard `auth.Cognito("us-east-1", "us-east-1_ABC123", …)` builds the issuer `https://cognito-idp.<region>.amazonaws.com/<user-pool-id>` and reads roles from `cognito:groups`, where a user pool keeps its groups. One piece is missing, and it is a piece the standard does not cover: **Cognito publishes no `end_session_endpoint`**. Ending the session there is a `GET /logout` on the managed login domain, with a different parameter name (`logout_uri`, not `post_logout_redirect_uri`): ```go p := auth.Cognito("us-east-1", "us-east-1_ABC123", id, secret, redirect) p.LogoutDomain = "example.auth.us-east-1.amazoncognito.com" // or your own domain ``` Without `LogoutDomain`, `Logout` clears the cookie, writes in the log that a local logout was all it could do, and returns to `AfterLogout` — it does not invent a federation that is not there. The return URL has to be in the app client's *Allowed sign-out URLs*, or Cognito refuses it. ## Clerk, and the half of a shortcut it can be `auth.Clerk` takes the *Frontend API URL* from the dashboard — `verb-noun-00.clerk.accounts.dev` in development, `clerk.your-domain.com` in production — with or without the scheme and the trailing slash, and turns all four spellings into the one issuer Clerk's discovery document declares: ```go p := auth.Clerk("verb-noun-00.clerk.accounts.dev", id, secret, redirect) ``` In the dashboard the application is an **OAuth application** (Configure → OAuth applications): the callback is your `RedirectURL`, and the client secret is shown once. Discovery, PKCE and the code exchange are the ordinary ones from there on. Two things Clerk does not have, and the shortcut says so rather than pretending parity: - **No role claim.** Clerk's `id_token` carries the organization (`org_id`), not the person's role in it. So roles fall back to the generic `roles`/`groups` pair — if your instance is configured to send a claim, name it in `Options.RoleClaims`. - **No `end_session_endpoint`**, and no equivalent address the way Cognito has one, with backchannel and frontchannel logout both off. `Logout` clears the cookie, returns to `AfterLogout`, and writes in the log that the Clerk session was left open. :::note Everything above was read from a real discovery document, not from the documentation: `https://clerk.clerk.com/.well-known/openid-configuration` is Clerk's own production instance. If yours announces more than that — a role claim, an end-session endpoint — `Options.RoleClaims` covers the first, and the second is used automatically. ::: ## Other providers Anything that speaks OIDC works through `auth.OIDC`, pointed at the issuer: roles come from `roles`/`groups`, and a different claim name goes in `Options.RoleClaims`. Google enters that way. A shortcut only saves you from getting the issuer wrong and knows where that provider keeps its roles — it is convenience, not capability. ## Protecting part of the app It is a `middleware.go`, like any other: ```go // app/dashboard/middleware.go func Middleware(c *trilha.Ctx, next trilha.Next) error { return sso.Require(c, next) } // app/dashboard/report/middleware.go — requires a role, not just a session func Middleware(c *trilha.Ctx, next trilha.Next) error { return sso.RequireAdmin(c, next) } ``` Below the middleware the page reads `flow.User(c)` and trusts it: the `*auth.User` is there, with `Subject`, `Email`, `Name` and `Roles`. Two different answers for two different situations: - **anonymous**: a browser goes to `/login?next=/dashboard`; any other client gets **401**. Redirecting an API call to an HTML form only produces a parsing error that is hard to understand on the other side. - **signed in, but without the role**: **403**. Sending someone who is already signed in to the login creates a loop — they sign in again, come back, and get 401 once more. ## Where roles live Each provider keeps them in one place, and `auth` already knows where to look: | Provider | Reads from | |---|---| | Entra ID | `roles` (app roles), `groups`, `wids` | | Keycloak | `realm_access.roles` and `resource_access[your-client].roles` | | Cognito | `cognito:groups` (the user pool groups) | | Clerk | nothing of its own: the `id_token` has `org_id`, not the role | | Generic | `roles`, `groups` | Keycloak roles that belong to **another** client do not count: whoever is `admin` in the accounting client does not become `admin` in yours. If your installation uses another claim name, add it to `Options.RoleClaims`. ## The session After the login, the `id_token` has done its job and is discarded. What stays is a cookie signed with `TRILHA_SECRET`, `HttpOnly`, `SameSite=Lax` and `Secure` under HTTPS, holding the essentials: identifier, name, e-mail, roles and deadlines. ```go auth.Options{ Absolute: 8 * time.Hour, // maximum lifetime, counted from the login Idle: 30 * time.Minute, // gone after being idle Store: auth.NewMemoryStore(), // optional: immediate revocation } ``` Without a `Store`, the session is stateless: it is valid on any replica and needs no database, but only truly ends when it expires. With a `Store`, the cookie carries an identifier and the logout deletes the record right away — that is what you want when you need to cut someone off now. The identifier changes on every login, so a cookie planted earlier does not become a valid session. ## What `auth` refuses Every item here is a known attack, and all of them have a test in the suite: - **the token's `alg`**: the list is fixed (RS256/384/512, ES256/384). Reading the algorithm from the token and obeying it is how JWT libraries get broken — `alg: none` gets through, or an RSA public key becomes an HMAC secret. - **`state`**: without it the callback can be forged by another site (CSRF on login). - **`nonce`**: ties the `id_token` to *this* request, against replay. - **PKCE (S256)**: a code stolen along the way is useless without the verifier. - **`iss`, `aud`, `exp`, `nbf`**: a legitimate token issued for another app or by another tenant is not valid here. Clock tolerance is 60 seconds. - **`next`**: only a path inside the app. `//evil.example` and `https://evil.example` become `/` — an open redirect is the classic way to lend credibility to phishing. - **unknown key**: the JWKS is fetched again when the provider rotates the key, but at most once per minute, so a forged token does not turn into one network request per HTTP request. Every refusal becomes a `SecurityEvent` of kind `auth` and lands in `trilha_security_events_total`, the counter from the [observability chapter](/trilha/learn/observability). ## Challenge Your app needs a route that only works for people who signed in **within the last five minutes** — a recent re-authentication before a sensitive operation, such as rotating an API key. :::solution ```go func recent(c *trilha.Ctx, next trilha.Next) error { u := flow.User(c) if u == nil || time.Since(u.IssuedAt) > 5*time.Minute { return trilha.RedirectCode("/login?next="+url.QueryEscape(c.Request().URL.Path), 302) } return next() } ``` `IssuedAt` is the moment of the login, and `Start` creates a new session on every round trip through the provider — so someone already signed in only has to pass through the provider's screen again, which usually lets them through without asking for the password. To force typing it, add `prompt=login` to the authorization parameters. ::: --- # UI kit Source: /trilha/learn/ui-kit Trilha's default component kit, compatible with shadcn/ui themes, and how it becomes yours to customize. Every project created with `trilha new` ships with the `ui` kit: typed components in Go (`ui.Button`, `ui.Card`, `ui.Field`...) that render classes from a small, prefixed CSS (`ui-*`), plus 200 lines of JavaScript for what HTML does not do on its own (tabs, disappearing toasts, conditional fields, light/dark theme). No dependencies: the three files live in `public/` and are yours. ```text public/ui.theme.css ← colors and radius: edit it or paste a ready-made theme public/ui.css ← the components; `trilha ui` updates it public/ui.js ← behaviors; `trilha ui` updates it ``` The theme contract is the one from [shadcn/ui](https://ui.shadcn.com) (MIT): the same variables, `--background`, `--primary`, `--radius`, in `oklch`. Generate a theme at ui.shadcn.com/themes or tweakcn.com, paste the `:root { … } .dark { … }` block into `ui.theme.css` and you are done: nothing in Go changes. Trilha uses neither React nor Tailwind; only the theme is compatible. ## Wiring the kit The generated layout already does this; in an existing project, run `trilha ui` and add: ```go h.Head(…, ui.Head(c)), // ui.theme.css, ui.css, saved theme, ui.js h.Body(ui.Body(), // theme font and colors ui.Header(ui.Brand("/", "My app"), ui.Nav(ui.NavLink("/", "Home", true)), ui.Spacer(), ui.ThemeToggle()), h.Main(ui.Container(children)), ui.Flashes(c), // where toasts show up, c.Flash included ) ``` ## Variants are attributes A component is a function returning `h.Node`; variants and sizes are class attributes you mix with any `h` attribute, in any order. `h` merges repeated `class` attributes into one. @demo ui-botoes ## Forms `ui.Field` joins label, control, help and error with the right `id`/`for` and `aria-*`. `ui.ShowWhen("field", "value")` shows the group only while the field has that value and **disables the hidden controls**, so they do not travel in the `POST`. Without JavaScript, all fields simply appear. @demo ui-formulario After a `POST`, render the error in the field itself (`ui.Error("Title is required")` + `ui.Invalid()` on the control) and a toast that disappears on its own: `ui.Toast("success", "Saved!", 4000)` inside the layout's toaster. The `examples/blog` app does both in `app/blog/novo/page.go`. ## Saying what happened, and asking before destroying A `POST` that works ends in a redirect, and the redirect eats the news. `c.Flash` writes it in a signed cookie, and the `ui.Flashes(c)` in the layout shows it on the page that follows: ```go c.Flash(ui.FlashSuccess, "Post deleted") return c.Redirect("/blog") ``` `ui.FlashInfo`, `ui.FlashSuccess` and `ui.FlashError` are the kinds. On a fragment answer there is no redirect to survive, so the messages travel in a header and `ui.js` shows them — the call in the handler is the same. Without `TRILHA_SECRET` nothing is written, and the app says so once in the log. Before something irreversible, `ui.Confirm` puts the question on the form itself: ```go h.Form(h.Method("post"), h.Action("/blog/"+p.Slug), trilha.CSRFInput(c), ui.Confirm("Delete this post?", "There is no undo."), ui.Submit(ui.Destructive(), h.Text("Delete"))) ``` `ui.js` holds the submit, opens the kit's dialog and only then lets it through. Without JavaScript the form submits straight away; when that is not good enough, ask on a page of its own (`GET /blog/{slug}/delete` rendering the same form), which works either way. ## Cards, tabs, progress @demo ui-card ## Dialog and toasts `ui.Dialog` is a native `<dialog>`: it closes with Esc, a click outside or `ui.DialogClose`; the form inside it does a normal `POST`. @demo ui-dialogo ## Tables with hierarchy `ui.Depth(n)` indents the first cell: it serves charts of accounts, category trees and any server-rendered *drill-down*. `ui.Num()` aligns numbers to the right. @demo ui-tabela ## Pagination and hints `ui.Pagination` renders page navigation as real links, so a page can be shared, reloaded and indexed. The current page is a `<span>` with `aria-current` — a link to where you already are is a link to nowhere — and the first page has no *previous*, so nothing is rendered for it. The window keeps the first page, the last one and the ones around the current, with an ellipsis over each gap, so the footer does not grow with the table. `ui.Tooltip` writes the hint into `title`, which is the browser's own tooltip and works with `ui.js` off. With the script on the page the `title` is removed — two tooltips is worse than none — a bubble with `role="tooltip"` takes its place, the target gets `aria-describedby`, and the hint answers to hover, keyboard focus and touch, closing with Escape. @demo ui-paginacao :::note The hint is a string on purpose. A hint with a link inside is a popover, and that is what `ui.Menu` is for. ::: ## Updating and customizing - `trilha ui` rewrites `ui.css` and `ui.js` when you update Trilha; it never touches `ui.theme.css`. If you edited `ui.css`, it warns and only overwrites with `--force`. - To change a component, edit `ui.css` (it is yours) or override it in `style.css`. For a new component, write the function in your own package: `func Price(v int) h.Node { return h.Span(h.Class("ui-badge price"), …) }`. - Icons: `ui.Icon("check")`, a small set from [Lucide](https://lucide.dev) (ISC). `ui.Icons()` lists the names. For others, paste the SVG into your own `h.Raw`. ## Challenge Build a sign-up form where the "Company" field only appears when "Type" is "Company" and, when submitted empty, the error shows in the field and a toast disappears after 3 s. :::solution ```go func Page(c *trilha.Ctx) (h.Node, error) { msg := c.Query("error") return h.Form(h.Method("post"), h.Class("ui-stack"), trilha.CSRFInput(c), ui.Field("type", "Type", ui.Select(h.ID("type"), h.Name("type"), h.Option(h.Value("individual"), h.Text("Individual")), h.Option(h.Value("company"), h.Text("Company")))), ui.Field("company", "Company", ui.Input(h.ID("company"), h.Name("company"), h.If(msg != "", ui.Invalid())), ui.Error(msg), ui.With(ui.ShowWhen("type", "company"))), ui.Submit(h.Text("Sign up")), h.If(msg != "", ui.Toaster(ui.Toast("error", msg, 3000))), ), nil } func POST(c *trilha.Ctx) error { if c.Form("type") == "company" && strings.TrimSpace(c.Form("company")) == "" { return c.Redirect("/signup?error=Company+is+required") } return c.Redirect("/signup/done") } ``` ::: --- # Interactivity Source: /trilha/learn/interactivity Swap one piece of the page and submit a form without a reload, from the same handler that serves the whole page. A Trilha page is a whole document: the browser navigates, the server answers, the screen blinks. That works well, but not on every screen — filtering a list or saving a form should not cost a reload. The way out here is the **fragment**: the same link and the same form as always, with one extra attribute. With JavaScript on, the `ui` kit asks for the page, the server answers with just that piece, and the browser swaps that element. With JavaScript off, the link navigates and the form submits — the server answers with the whole page, because nobody asked for a fragment. No new route, no new handler, no dependency. ## One more question in the handler `c.Fragment()` returns the id the client wants to swap, or `""` on a normal navigation: ```go func Page(c *trilha.Ctx) (h.Node, error) { c.SetTitle("Clientes") return tela(c, c.Query("q")), nil } // tela is the whole page when there is no fragment, and the piece when there is: // the element being swapped must carry the same 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)), ) } ``` When the request carries the `Trilha-Fragment` header, Trilha: - **skips the route's layouts** (no `<html>`, no `<head>`, no navigation bar); - writes only the nodes you returned, with no document envelope and no dev server script; - answers with `Vary: Trilha-Fragment`, so a cache does not keep the piece in place of the page. Everything else stays the same: middleware runs, CSRF is checked, the status is the one you sent. `c.Fragment()` is just a question. ## The link and the form In the HTML, `ui.Swap("id")` marks who takes part: ```go ui.ButtonLink("/clientes?pagina=2", ui.Swap("lista"), h.Text("Next")) h.Form(h.Method("post"), h.Action("/clientes"), ui.Swap("tela"), trilha.CSRFInput(c), // fields… ) ``` `ui.js` intercepts the click (left button only, no Ctrl/Cmd, same origin) and the submit, does a `fetch` with the header, and swaps the element for the HTML that came back. While it waits, the target gets `aria-busy="true"` (the kit's CSS dims the block and shows the progress cursor). `ui.NoPush()` on a link keeps history untouched. ## After the POST A `POST` that redirects keeps redirecting — inside a fragment too. Since `fetch` would follow the 303 on its own and bring the new page back as a piece, Trilha answers **204 with the `Trilha-Location` header**, and `ui.js` navigates for real. Post/Redirect/Get survives. When staying on the same screen makes more sense, answer with the updated piece: ```go func POST(c *trilha.Ctx) error { in, errs := ler(c) if len(errs) > 0 { return c.Render(422, tela(c, in, errs, "")) // the form with its errors } clientes.Criar(in) if c.Fragment() != "" { return c.Render(200, tela(c, clientes.Cliente{}, nil, "Cadastro salvo!")) } return c.Redirect("/clientes?ok=1") } ``` On **422** `ui.js` focuses the first field with `aria-invalid="true"` — what the browser would do by itself on a reload. Otherwise it gives focus (and the caret position) back to the field in use, looking it up by `id` or by `name`. ## When the fragment does not work out The kit **never leaves the screen stuck**: if the answer is 5xx, if the network drops or if the piece comes back without the expected id, it gives up and does the real navigation — the link becomes `location`, the form becomes `form.submit()`. The user sees the page reload; they do not see a click that did nothing. ## After the swap New elements arrive hydrated: `[data-ui-fade]` and `[data-ui-show-when]` work again on their own. If you have behavior of your own, listen for the event: ```js document.addEventListener("trilha:swap", (e) => { // e.detail.target = the new element, e.detail.status = the response status }); ``` `window.ui.swap(id, html, status)` and `window.ui.hydrate(el)` are exposed for whoever needs to do the swap by hand. ## The island: what a fragment cannot do A fragment always comes from the server. An editor with a live preview, a canvas, a map that drags: the state is on the client and there is no round trip to make. That is an **island** — a piece of the page that brings its own module, with everything around it staying plain HTML. ```go c.Island("/editor.js", map[string]any{"wpm": 200}, h.Class("editor"), ui.Textarea(h.Name("corpo")), // the fallback: still a form field h.P(h.Data("info", ""), h.Hidden()), // filled in by the module ) ``` ```html <div data-trilha-island="/editor.js?v=9c1f" data-trilha-props="{"wpm":200}" class="editor">…</div> ``` The module is an ordinary ES module in `public/`, and its default export is the mount: ```js export default function (el, props) { const area = el.querySelector("textarea"); area.addEventListener("input", () => { /* … */ }); } ``` Four things fall out of that shape: - **The children are the fallback, and the server renders them.** Script blocked, still on the way, or 404: the page is what it always was. The island adds, it does not carry. - **The props are data.** They are escaped as an attribute and read back with `JSON.parse` — a value from the database cannot become markup. Anything `encoding/json` serializes goes; what does not serialize warns in the log and leaves the fallback alone. - **No bundler and no global hydration.** The module is a file in `public/`, addressed through `Asset` (so the URL carries the content hash), and only the islands present on the page are mounted, each one once. The loader is a single inline script with the request nonce, which is why the default CSP accepts it without `unsafe-inline`. - **An island that arrives inside a fragment mounts too**: the loader listens for `trilha:swap`. What it needs is to be on the page already — that is, the page rendered at least one island of its own. ### The escape hatch The island is the boundary where another library is allowed in, and where its cost stops. Web Components need nothing from here — `customElements.define` and the tag is the island. For Alpine, htmx or anything else, drop the file in `public/` and import it from the island's module; for React, an ESM build in `public/` and a `createRoot(el)` inside the mount. The page around it is not asked to become a component, and nothing else in the project learns about the choice. The default CSP is `script-src 'self'`, so a module from a CDN is refused until you widen it — a decision, not an accident. ## The whole page, without the reload A fragment swaps a piece of the page a handler chose. Navigation is the other half: the next page is a *different* page, and what should not blink is everything around it — the header, the sidebar, the scroll position of a long list. ```go // app/painel-/layout.go return h.Section(h.Class("app"), ui.Navigate("conteudo"), ui.NavigateScript(c), ui.Sidebar(ui.Nav( ui.NavLink("/painel", "Dashboard", cur == "/painel"), ui.NavLink("/relatorio", "Report", cur == "/relatorio"), )), h.Div(h.Class("app-content"), children), ), nil ``` `ui.Navigate(id)` marks a region: a click on a same-origin link inside it fetches the next page and replaces `#id` with the same element from it. `ui.NavigateScript(c)` loads the behavior — a separate file from `ui.js`, so an app that does not navigate this way does not download it. Nothing changes on the server: `/relatorio` is the same route, answering the same document. Reloading, opening in another tab, or arriving with JavaScript off gives the same page. Off by default, and off per link: ```go ui.ButtonLink("/relatorio.pdf", ui.NoNavigate(), h.Text("Download")) ``` The browser keeps its habits — Back and Forward work and restore the scroll position of the entry they return to, `Cmd`-click opens a tab, `target` and `download` are untouched. The kit adds `aria-busy` while it waits, moves focus to what came in, and fires `trilha:swap`, so an island inside the new page mounts. A second click cancels the first request; a 5xx, a redirect or a page without that id gives up and navigates for real. The rule of thumb: **fragment** when a handler answers a piece, **navigation** when the answer is a page and the frame around it should stay. ## The file, and the bar that says how far it got Sending a file is the one place where "the screen blinks" is not the problem — the problem is that nothing happens for thirty seconds. The browser knows how far the upload got; it just has no way to say so from a plain form submit. ```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", "File", ui.Input(h.ID("arquivo"), h.Name("arquivo"), h.Type("file"), h.Required())), ui.UploadBar(), ui.Submit(h.Text("Send")), ) ``` `ui.UploadTo(id)` sends the form with XHR and swaps `#id` with the answer; `ui.UploadBar()` is the `<progress>` the kit fills in from the browser's own progress event; and `ui.UploadScript(c)` loads the behavior — its own file again, so a page without an upload does not download it. With JavaScript off, none of that exists and the form is what it always was: it posts, the server answers, the page reloads. On the server there is no new API. The request carries `Trilha-Fragment`, so the same handler that renders the page answers the piece: ```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()) // the piece, with the same id } return c.Redirect("/anexos") // no JavaScript: Post/Redirect/Get } ``` ### The limit is the app's; the exception is the route's A body is capped at `Config.MaxBodyBytes` (1 MiB by default) — that cap is what keeps one request from eating the server's memory, and it should stay where it is for every route that receives a form. The route that receives files says so for itself, in its `middleware.go`: ```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() // a slow connection is not an error } return next() } ``` In the middleware, not in the handler: CSRF parses the form before the handler runs, so by then the body has already been read under the old limit. Everything else in the app keeps the 1 MiB, and going over 8 MiB is still a 413 with the usual message. ## What this is not It is not a SPA. There is no client router, no shared state, no component hydration and no DOM diffing — the swap is `outerHTML`, and the source of truth is still the server. A screen that needs rich local state (an editor, a canvas) deserves its own JavaScript, and the island above is where that JavaScript goes; the fragment solves the common case, which is most screens. Worth remembering the security boundary: `Trilha-Fragment` is a custom header, so a third-party site cannot send it without a preflight — and Trilha answers no preflight. A fragment only ever goes out to your own origin. The `examples/cadastro` app uses both: a search that filters the list and a form that saves without reloading, both working with JavaScript turned off. ## Challenge Make the list swap as the user types, without waiting for the button — and without firing a request per keystroke. :::solution ```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()` fires the same `submit` event the kit already listens for, so `data-trilha-target` still applies — and the form keeps working on the button click for whoever has no JavaScript. ::: --- # AI and agents Source: /trilha/learn/ai-and-agents Call a model, give tools to an agent, hand the conversation over between agents, use and expose MCP, and stream the answer. The `ai` package speaks OpenAI's *chat completions* protocol, which today is the lingua franca of providers: OpenAI, Groq, Mistral, OpenRouter, Ollama, LM Studio and vLLM accept the same requests. You configure URL and model through environment variables and the code does not change. Like all of Trilha, `ai` and `ai/mcp` bring no dependencies outside the standard library. ```bash export OPENAI_API_KEY=sk-... # or any token from your provider export OPENAI_BASE_URL=http://localhost:11434/v1 # local Ollama, for example export TRILHA_AI_MODEL=qwen2.5:7b ``` ## One call ```go cli := ai.NewFromEnv() resp, err := cli.Chat(ctx, ai.Request{Messages: []ai.Message{ ai.System("Answer in one sentence."), ai.User("What is a layout in Trilha?"), }}) fmt.Println(resp.Text()) ``` `Stream` delivers the answer in chunks; `Delta.Content` carries the text and `Delta.ToolCalls` the tool arguments as they arrive. ## Tools A tool is a name, a description, a JSON Schema for the arguments and a Go function. `ai.Typed` decodes the arguments into a struct for you: ```go weather := ai.NewTool("weather", "Current temperature in a city.", ai.Schema(`{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`), ai.Typed(func(ctx context.Context, in struct{ City string }) (string, error) { return fetchTemperature(ctx, in.City) })) ``` Errors and panics inside the tool become text for the model ("error: ..."), never bring the server down. The model reads the error and decides what to do, which is the behavior you want in an agent. ## Agents An agent is instructions + tools. `ai.Run` executes the loop model → tools → model until the final answer (or `MaxTurns`, default 10). Tool calls in the same round run in parallel. ```go assistant := &ai.Agent{ Name: "Assistant", Instructions: "Answer briefly.", Tools: []*ai.Tool{weather}, } res, err := ai.Run(ctx, cli, assistant, "Is it cold in Curitiba?") fmt.Println(res.Output) // final text fmt.Println(res.Steps) // each tool called, with arguments and output ``` `res.Messages` is the whole conversation; pass it as history in the next call to keep the context: `ai.Run(ctx, cli, assistant, "And tomorrow?", res.Messages...)`. ## Multi-agent Three ways to compose agents, from the simplest to the most controlled: - **Handoff**: `Handoffs: []*ai.Agent{translator}` creates the `transfer_to_translator` tool. When the model calls it, the translator takes over the conversation: the instructions change, the history stays. It is the "triage → specialist" pattern. - **Agent as a tool**: `researcher.AsTool(cli, "Researches a topic")` makes the main agent call the other one as a function and keep the conversation itself. - **Orchestration in Go**: `ai.Parallel` runs several agents at once and `ai.Chain` passes one's output as the next one's input. You keep control in code, without depending on the model "remembering" to delegate. ## Streaming to the browser `c.Stream()` turns the response into Server-Sent Events and `ai.RunStream` delivers the agent's events (text, tool call, result, handoff, end): ```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, assistant, 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 } ``` On the client, a `fetch` with `POST` and reading the body through `ReadableStream` is enough (the browser's `EventSource` only does `GET`). The `examples/assistente` app ships the complete `chat.js` in 60 lines. ## MCP: use and expose tools The *Model Context Protocol* standardizes how hosts (Claude, Cursor, VS Code...) discover and call tools. Trilha implements both sides. **Client**: the tools of any MCP server become `*ai.Tool` for your agents. ```go fs, err := mcp.Dial(ctx, mcp.Stdio("npx", "-y", "@modelcontextprotocol/server-filesystem", ".")) tools, err := fs.Tools(ctx) agent.Tools = append(agent.Tools, tools...) ``` `mcp.HTTP(url, headers)` connects to remote servers (Streamable HTTP). **Server**: your app's tools become available to external hosts with one route: ```go // app/mcp/route.go var server = mcp.NewServer("my-app", "1.0", weather, findOrder) func POST(c *trilha.Ctx) error { return server.ServeHTTP(c) } ``` Protect the route like any API (middleware with a token, rate limit). The server emits `Mcp-Session-Id` on `initialize` and rejects messages without a session. For hosts that prefer stdio, `server.ServeStdio(ctx, os.Stdin, os.Stdout)` in a separate `main`. ## Your project, explained to an agent The chapters above are about the agent your app runs. This section is about the agent that edits your app — Claude Code, Cursor, Copilot — and the file it reads first. ```bash trilha agents # in a project that already exists trilha new loja --agents # at creation time ``` `--agents` is a flag of `new`; in a project that already exists the command is `trilha agents`, and the upgrade from an older version is five lines in [Migration](/trilha/cookbook/migration#turning-on-the-agent-files-in-a-project-that-already-exists). It writes two files at the root. `AGENTS.md` is the framework's: the three conventions, the commands and what each one checks, and what not to do (edit `trilha_gen.go`, add a dependency, put a secret in the code). `CLAUDE.md` is yours: three lines pointing at `AGENTS.md`, and room for whatever this repository needs. Neither exists unless you ask. Support for agents is a choice of the team, not a convention of the framework, so `trilha new` on its own leaves your project exactly as it did before. `AGENTS.md` is refreshed the way the ui kit is: it carries the hash of its own body, so an untouched copy from an older version is rewritten in silence and one you edited needs `--force`. Add your rules to it and they survive the next upgrade — the command will refuse rather than overwrite them. Two commands exist for that reader in particular. `trilha ctx` prints the map of the project — every route with its file and methods, each API operation with what it receives and returns, the types involved, what `app/setup.go` provides — in one read instead of a dozen file openings, with `--json` when the reader is a tool. `trilha check` is the single gate before calling the work done: `gen`, `gofmt`, `vet`, `test`, `audit` and `openapi` in one command, stopping at the first failure, with `--fix` for the two problems nobody should be told twice. Every problem it reports carries the file, the line and the sentence that resolves it, so finding that out costs no extra round trip. Both are in [CLI](/trilha/reference/cli#trilha-check). :::note This documentation is also published as plain text, which is much cheaper for an agent to read than the HTML around it: [/llms.txt](/trilha/llms.txt) is the index, one line per page, and [/llms-full.txt](/trilha/llms-full.txt) is everything concatenated, code blocks included. The Portuguese ones are at `/pt/llms.txt` and `/pt/llms-full.txt`. ::: ## Challenge Give the example's agent a `find_post` tool that queries the blog API (`/api/posts/{id}`) and ask: "summarize the post ola-trilha". :::solution ```go findPost := ai.NewTool("find_post", "Finds a blog post by 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 not found: %s", in.Slug) } return p.Title + "\n\n" + p.Body, nil })) assistant.Tools = append(assistant.Tools, findPost) ``` Being an in-process call, there is no HTTP and no key: the tool reads the repository directly. When the source is external, use `ctx` to honor the client's cancellation. ::: --- # Examples Source: /trilha/learn/examples Complete apps in examples/, from basic to complex, and what each one teaches. The examples are real apps, with integration tests that run in the repository's `make test`. Each has a short `README.md`. Run any of them with `trilha dev` inside the folder (or `go run ../../cmd/trilha dev` from the clone). :::note The example apps are written in Portuguese: folder names, identifiers and UI texts (for instance `app/blog/novo` is "new post", `cadastro` is "sign-up", `orcamento` is "budget"). The code is the same Trilha you read about in English here; only the words differ. ::: | Level | Folder | What it teaches | |---|---|---| | Basic | `examples/blog` | every file convention, nested layouts, route groups, JSON API, middleware, signed session, `tmpl` | | Medium | `examples/cadastro` | a form with rules: conditional fields, server-side validation with per-field errors, dependent select, disappearing toast, responsive layout | | Complex | `examples/orcamento` | tree-shaped domain (chart of accounts), aggregation, drill-down through a dynamic route, nested and recursive components, dialog with a form, period filter, CSV | | SSO | `examples/sso` | OpenID Connect login with Entra ID or Keycloak, protected area, required role, federated logout | | AI | `examples/assistente` | streaming chat, agent with tools, handoff, MCP server | ## Medium: sign-up (`cadastro`) The form model is a struct with `form` tags; `c.Bind(&in)` fills it (nested structs are flattened, with an optional prefix): ```go type Cliente struct { Tipo string `form:"tipo"` // type Nome string `form:"nome"` // name Endereco Endereco // cep, rua, uf, cidade (address) Cobranca Endereco `form:"cob_"` // cob_cep, cob_rua... (billing address) Novidades bool `form:"novidades"` // newsletter } ``` Validation is a pure function returning `trilha.FieldErrors`, and `POST` decides: ```go func POST(c *trilha.Ctx) error { var in clientes.Cliente if err := c.Bind(&in); err != nil { return err // invalid conversion → 422 } clientes.Normalizar(&in) // drops what the type does not use if errs := clientes.Validar(in); errs.Any() { return c.Render(422, tela(c, in, errs)) // same page, with layouts } clientes.Salvar(in) return c.Redirect("/?ok=1") // PRG + disappearing toast } ``` On screen, each field reads its value and its error from the same place: ```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")) ``` Conditional groups use `ui.ShowWhen("tipo", "pj")`: hidden ones are disabled and do not travel in the `POST`; and since anyone can craft the `POST` by hand, `Normalizar` clears what the type does not use before validating. The city `<select>` is filled by `GET /api/cidades?uf=` with 20 lines of `app.js`; on a 422 the server already returns the cities of the chosen state, so the page comes back complete without JavaScript. ## Complex: budget (`orcamento`) The chart of accounts is a tree (`Conta{Codigo, Nome, Filhos}`); budgeted and actual values of a summary account are the sum of its children, computed on read. The components mirror the tree: `Linha` renders the account and calls itself for the children, `ui.Depth(n)` indents: ```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) })) } ``` The drill-down is the route `app/contas/codigo_/page.go`: breadcrumb with `Caminho()`, children (same `Tabela`) or entries (leaf account). The entry form is **a single one** (`FormLancamento`), used inside `ui.Dialog` in the overview and in the drill-down, and on its own at `/lancamentos`; `POST` validates with `c.Bind` + `plano.Validar` and, on a 422, `app.js` reopens the dialog because it found `.ui-field-error` inside it. `voltar` (a hidden field) says where to redirect on success. The export lives in `app/api/relatorio.csv/route.go`, a folder with a dot in its name. ## SSO: Entra ID and Keycloak `examples/sso` is the whole login flow in three routes of two lines each. The `auth` package handles PKCE, `state`, `nonce`, the code exchange and `id_token` validation; the app only forwards: ```go // app/entrar/route.go ("entrar" = sign in) var Kind = trilha.KindPage func GET(c *trilha.Ctx) error { return sso.Start(c) } ``` Protecting a subtree is a `middleware.go`, like any other: ```go // app/painel/middleware.go ("painel" = dashboard) func Middleware(c *trilha.Ctx, next trilha.Next) error { return sso.Require(c, next) } // app/painel/relatorio/middleware.go — role, not just session func Middleware(c *trilha.Ctx, next trilha.Next) error { return sso.RequireAdmin(c, next) } ``` Below the middleware, the page reads `sso.User(c)` without checking anything. An anonymous browser is sent to `/entrar?next=…`; a call to `/api` gets 401 as JSON, because redirecting an HTTP client to a form only produces a confusing parsing error. No secret lives in the code: the provider comes from environment variables, and without them the app still starts and says what is missing. ## What became framework Writing the two examples exposed repetition that is now API: `c.Bind`, `trilha.FieldErrors`, `c.Render` (a page with layouts from a `POST`), `ui.Errors`, `ui.InvalidIf`, `ui.SelectOptions`, `ui.Checked`. That is the constitution's criterion: an example that needs repetitive code points to a gap in Trilha, not in the example. ## Challenge In the budget app, add a "Year" column to the drill-down that sums the account's twelve months. :::solution ```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 } ``` Call it from `Linha` and add the two cells; since aggregation is recursive, the column already works for summary accounts. ::: --- # Testing Source: /trilha/learn/testing A test client in the framework itself: one request, a whole session, CSRF that just works. An app made with Trilha is an `http.Handler`, so it can always be tested with `httptest` and nothing else. The problem is what comes before the first assertion: a client, a cookie jar, and the CSRF token copied from the cookie into the form. That is fifty lines every project writes again — and gets wrong the first time, because the double-submit only passes when the cookie comes back in the request. The framework already issues that cookie and already checks that token, so it ships the client. No external test framework, no assertion library: `package trilha` never imports `testing`. ## One request ```go func TestListaPosts(t *testing.T) { res := trilha.TestRequest(t, newApp(), "GET", "/api/posts") res.WantStatus(200).WantContains(`"slug"`) } ``` `newApp()` is the function the generator writes in `trilha_gen.go`: the same app that serves in production. The request goes through the real path — mux, middlewares, layouts, CSRF, error negotiation — and what comes back is the recorded response. Assertions chain and never return an `error`. In a test the value of an error is stopping with the right message, so a failure prints the status, the target and the body: ```text GET /api/posts: status = 500, want 200 {"status":500,"title":"Internal Server Error","request_id":"…"} ``` ## A whole session When the test is a flow — open the form, submit it, follow the redirect — the client keeps the cookies the app sets: ```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": {"Hello"}}) res.WantStatus(303).WantHeader("Location", "/blog/hello") c.Get("/blog/hello").WantContains("Hello") } ``` `Get`, `PostForm` and `PostJSON` are shortcuts for `Request`, which takes any method. A redirect is not followed on its own: the test that wants the destination asks for the destination, because where a `303` lands is an assertion, not a detail. ## CSRF passes by default Every request the helpers send carries the CSRF cookie, and every method with a body carries the same value in the `X-CSRF-Token` header. :::note This is not a hole in the protection. Double submit asks the browser to prove it can read its own cookie, and the test client proves exactly that: cookie and token come from the same place. What the check rejects — a form posted from another site, which cannot read the cookie — is still rejected. ::: A test that wants to prove the rejection asks for it: ```go c.PostForm("/blog/novo", form, trilha.WithoutCSRF()).WantStatus(403) ``` ## A signed session without logging in `WithSigned` writes a cookie signed with the app's own signer — the same one `c.SetSigned` uses in a handler. The admin page stops requiring a `POST /login` before every case: ```go res := trilha.TestRequest(t, newApp(), "GET", "/admin", trilha.WithSigned("sessao", "ana")) res.WantStatus(200) ``` The signature is real: a session forged by hand still fails, which is what `trilha.WithCookie("sessao", "ana|9999999999|fake")` is for when you want to test the rejection. ## One `route.go`, one page `TestRoute` mounts a throwaway app in `Dev` around a single route, so a handler can be tested where it lives, before it is registered anywhere: ```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`) ``` The pattern is what resolves `{id}`, so `c.Param("id")` answers `7` — the router is doing the work, not a mock. `TestPage` does the same for a page and also hands back the rendered node, layouts already applied: ```go res := trilha.TestPage(t, trilha.Route{Page: Page, Layouts: []trilha.LayoutFunc{Layout}}, "/sobre") res.WantStatus(200) if h.Render(res.Node) == "" { t.Fatal("empty page") } ``` `res.Body` holds the whole document, with the layout around it; `res.Node` is just what the page returned. Asserting on the node survives a change of layout, which is usually what you want. Both build the app for you; `trilha.WithApp(a)` uses yours instead, when the route depends on something `Setup` provided with `trilha.Provide` — `trilha.Use[T](a)` reads it back in the test itself. ## The options | Option | What it does | |---|---| | `WithApp(a)` | uses your app in `TestRoute`/`TestPage` instead of a throwaway one | | `WithHeader(name, value)` | one header (`Accept`, `Trilha-Fragment`, `Authorization`) | | `WithCookie(name, value)` | one raw cookie | | `WithSigned(name, value)` | one cookie signed by the app, valid for an hour | | `WithForm(values)` | body as `application/x-www-form-urlencoded` | | `WithJSON(v)` | body as `application/json` | | `WithBody(contentType, body)` | body exactly as written (multipart, CSV, a broken JSON) | | `WithoutCSRF()` | sends nothing about CSRF, to test the refusal | ## The response `TestResponse` embeds `*httptest.ResponseRecorder`, so `Code`, `Body` and `Header()` are still there for whatever the ready-made assertions do not cover. | Method | What it does | |---|---| | `WantStatus(code)` | fails with the body when the status differs | | `WantContains(text)` | fails with the body when the text is missing | | `WantHeader(name, value)` | fails when the header differs | | `JSON(&v)` | decodes the body into `v`, failing with the body on invalid JSON | | `Cookie(name)` | the cookie this response set, or `nil` | | `Node` | the page's node, filled in by `TestPage` | `Cookie` is how you assert on a logout: what proves the session is gone is the app deleting the cookie, not the redirect that follows. ```go if res.Cookie("sessao") == nil { t.Fatal("logging out should clear the session") } ``` ## Race and fuzzing Two bugs never show up in a deterministic suite. One is the data race: two requests touching the same field of the app at the same time — the asset cache, the metric counters, the rate-limit buckets. The other is the input nobody wrote: a path with `%2e%2e`, a cookie with the signature of another key, a form body that is a single `;`. The framework's own suite covers both, and the two commands are one line each: ```bash make race # go test -race ./... make fuzz # 20s on each fuzz target, same as CI ``` `make race` is only worth what the suite gives it to look at, so there is a test (`TestConcorrencia`) that hits the same app from 32 goroutines: it logs in, reads a signed page, calls an API route, asks for a static file and reads `/metrics`. Without it the detector would run over an app answering one request at a time and find nothing. The fuzz targets state an invariant rather than an expected output: | Target | What it holds | | --- | --- | | `FuzzRouteMatch` | no target crashes the app or serves a file from outside `public/` | | `FuzzBindForm` / `FuzzBindJSON` | if `Bind` returns no error, every `validate` rule holds | | `FuzzSignedVerify` | a cookie is only accepted if some key would have produced it, and it has not expired | | `FuzzParseTraceparent` | the trace id is either empty or hex that came from the header | | `FuzzRenderEscapes` | whatever goes into `h.Text` or an attribute comes back escaped | Fuzzing in your own app is the same shape. Write the target next to the code it tests, seed it with the cases you already know, and assert the property — not the output: ```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 When fuzzing finds a failure, Go writes the input to `testdata/fuzz/<Target>/`. Commit that file with the fix: from then on `go test ./...` replays it, and the bug cannot come back quietly. ::: ## Challenge Write a test proving that the blog's form rejects a title longer than the limit and shows the message on the page, without going through the API. :::solution ```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") } ``` The form answers `422` with the page re-rendered — the same body a browser would show — so one request covers the validation and the message. The CSRF token went along on its own. ::: --- # Development and production Source: /trilha/learn/dev-and-production What trilha dev does under the hood, how to publish a binary and how to configure through environment variables. ## `trilha dev` The command listens on `:3000` and runs your app on an internal port, forwarding the requests. On every saved file: 1. it regenerates `trilha_gen.go` if the `app/` tree changed; 2. it recompiles with `go build`; 3. it starts the new process, waits for it to answer and only then stops the old one; 4. it notifies the browser through an event (SSE), which reloads. Changes only in `public/` skip steps 1 to 3. A compile error becomes a page with the output of `go build`; fix it and the page goes away. The app process runs with `TRILHA_ENV=dev`, which turns on stack traces in error pages and turns off the static file cache. ## The route inspector While `trilha dev` runs, `http://localhost:3000/_trilha/routes` answers with the map of the app: every route in the order the router decides, with its kind, its methods, the folder it comes from, the layouts that wrap it (outermost first) and the middlewares that run before it — the two things `trilha routes` cannot show, because they are chains, not lines. The box at the top answers the question that usually brings someone there: type `/blog/hello` and the page says which pattern serves it and what each parameter is worth. The answer comes from an `http.ServeMux` built from your patterns, so it is the router deciding, not a second implementation of the precedence rules. The page is served by the dev supervisor, not by your app: it is not in the binary `trilha build` produces, and the same URL in production is a 404 like any other. There is nothing to turn off before publishing. ## `trilha build` ```bash trilha build # → bin/agenda TRILHA_ENV=prod PORT=8080 ./bin/agenda ``` The binary is static (`CGO_ENABLED=0`), embeds `public/` and needs neither the CLI nor any file next to it. A `Dockerfile` fits in four lines: ```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"] ``` ## Environment variables | Variable | Effect | |---|---| | `PORT` or `ADDR` | listening port or address (default `:3000`) | | `TRILHA_ENV` | `dev` or `prod` (default `prod`) | | `TRILHA_BASE_PATH` | URL prefix when the app lives under a subpath; use `c.Base()` in links | | `TRILHA_EXPORT` | output folder: instead of serving, export the static site and exit | | `TRILHA_DEV_RELOAD` | `off` disables the reload script injection in dev (snapshot tests, HTML comparison); stack traces and `no-cache` stay | Other settings (body limit, logger, CSRF in APIs) live in `trilha.Config`, which the generated file builds with `trilha.ConfigFromEnv()`. ## Startup with `setup.go` Opening a database, loading a cache, validating variables: all of that goes in `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 // aborts startup with the message in the terminal } trilha.Provide(a, db) return nil } ``` A page reads it back by the same type: `db := trilha.Use[*sql.DB](c)`. Do not keep the pool in a package variable — it works until a second app exists in the same process (a host that mounts two, a test that builds another one) and then both share it. `Values()` is still there for glue by name. See [Dependencies](/trilha/reference/app#dependencies). ## `trilha export` If every page is static (a blog, documentation), export HTML and publish on any host: ```bash trilha export -o out --base /agenda ``` Pages with parameters are included when `Setup` declares them with `a.AddExportPath("/events/x")`. Pages that answer a same-site redirect become a small HTML stub pointing to the destination. The site you are reading was generated this way. An exported path whose last segment has a dot is written as that file, not as a folder with an `index.html` inside it. That is how a route can produce `out/llms.txt` or `out/feed.xml`: ```go func Setup(a *trilha.App) error { a.AddExportPath("/llms.txt", "/feed.xml") return nil } ``` It is the same rule the scanner uses for a folder name with a dot in it (`app/llms.txt/route.go` answers `/llms.txt`), so the route and the exported file agree without a second convention. ## Assets and cache Publishing new HTML with old CSS is the bug nobody can reproduce ten minutes later. The cause is always the same: the file's address did not change when its content did, and some cache layer — the browser, a CDN, GitHub Pages — still holds the old version. `Asset` puts the content hash in the URL: ```go h.Link(h.Rel("stylesheet"), h.Href(c.Asset("/style.css"))) // /style.css?v=8f3a1c92 ``` With that, a long cache becomes safe: ```go cfg.StaticCacheControl = "public, max-age=31536000, immutable" ``` Whoever asks for the right versioned URL gets the one-year cache; whoever asks for `/style.css` without a version falls under the normal rule. In `dev` nothing is immutable and the hash follows the file, so saving the CSS and refreshing the page is enough. `trilha export` needs no option: the exported HTML comes out with the same URLs, because the same layout generates it. `trilha audit` warns when it finds `immutable` in a project that does not use `Asset` — the combination that freezes a file for a year at the wrong address. ## Secure by default `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` and `Referrer-Policy` headers on every response; limited body; CSRF on forms; static files without *path traversal*; logs with method, path, status and duration, never with body or cookies. Errors in production show an opaque page and go to the log with the `request_id` that appears in the `X-Request-ID` header. ## Challenge Publish the agenda on a server with `systemd` and make the service restart on its own if it crashes. :::solution ```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 ``` ::: --- # Troubleshooting Source: /trilha/learn/troubleshooting Errors that show up in the first minutes and what each one means. ## `zsh: command not found: trilha` `go install` placed the binary in `~/go/bin` (or whatever `go env GOPATH` shows plus `/bin`), and that folder is not in your `PATH`. Add it to `~/.zshrc` or `~/.bashrc` and open a new terminal: ```bash export PATH="$HOME/go/bin:$PATH" ``` ## `verifying module ... 404 Not Found` on `go install` The module lives in a private repository, or it just became public and the proxy does not know it yet. The `sum.golang.org` checksum database can only verify public modules. For a private module, tell Go not to verify: ```bash go env -w GOPRIVATE=github.com/your-org/* ``` For a freshly published module, prefer installing by tag (`@v0.1.0`) instead of `@latest`. ## `app/ directory not found` CLI commands run at the project root, the folder containing `app/`. If the app lives inside a larger module (like `examples/blog` in Trilha's repository), run the CLI inside that subfolder: the import path is computed from the nearest `go.mod`. ## `E_NO_PAGE_FUNC` or `E_NO_METHOD` The file exists, but the expected function is not exported with the right name. `page.go` needs `Page`; `route.go` needs at least one of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`; `layout.go` needs `Layout`; `middleware.go` needs `Middleware`. A wrong signature is a compile error in `trilha_gen.go`, pointing at the package. ## `E_UNUSED_METHOD_MIDDLEWARE` A `MiddlewarePOST` (or `GET`, `PUT`, `PATCH`, `DELETE`) in a `middleware.go` that reaches no route serving that method in its folder or below it. Usually the method moved and the rule stayed, or the name has a typo. Either delete it, or give the route the method it is meant to guard — a permission that guards nothing is worse than no permission, because it reads like protection. ## `E_DUPLICATE_ROUTE` Two folders produce the same URL, almost always because of a route group. `app/events/` and `app/organizer-/events/` both answer at `/events`. Rename one of them. ## `E_HIDDEN_ROUTE` A `page.go` or a `route.go` inside a folder whose name starts with a dot. The scanner skips those folders, so the route would never answer — it used to disappear without a word, and the only symptom was a 404. Rename the folder without the leading dot, or, if the folder is meant to stay out of the routing, start its name with `_`. The single dot folder that *is* routed is `.well-known` (see [conventions](/trilha/reference/conventions#folders)). ## `E_UNROUTABLE_METHOD` `func HEAD`, `func TRACE` or `func CONNECT` in a `route.go`. The router does not take those from a file, so the function used to compile and answer nothing: the request fell into the 405 the fallback writes before any middleware. HEAD is not missing — since Go 1.22 the router answers it with the `GET` handler, so write the response there. `OPTIONS`, on the other hand, is a handler like the others, and a route that only needs the preflight can declare `var CORS` instead of writing it (see [conventions](/trilha/reference/conventions#cross-origin-on-one-route)). ## The preflight answers 405 The route serves no `OPTIONS`. Either declare `var CORS = trilha.CORS{...}` in its `route.go` — the framework then answers the preflight from the policy — or write `func OPTIONS` by hand. `Config.CORS` also answers, but for the whole app: use it when every route shares the policy, not to open three paths. ## The form answers 403 `trilha.CSRFInput(c)` is missing inside the `<form>`, or the form page was opened before the cookie existed (for instance, a `curl` straight to the `POST`). Open the page with `GET` first, as a browser would, or send the token in `X-CSRF-Token`. ## `trilha dev` says there is no binary here The folder declares a package other than `main`, so `trilha gen` wrote an importable package with `NewApp()` and no `func main()` — an app meant to be mounted by a host binary (`mux.Handle("/", crm.NewApp().Handler())`). Run the host, not this folder. If the package clause was a mistake, fix it in the hand-written file and generate again; the generated file follows whatever the folder declares. See [CLI](/trilha/reference/cli#an-app-inside-a-binary-that-already-exists). ## Port 3000 is busy ```bash trilha dev --addr :3001 ``` ## The browser does not reload The reload script is only injected when the response is HTML and goes through the layout. A page returning `c.Text(...)` or `c.JSON(...)` does not get the script. Also check whether a proxy (nginx, an extension) is blocking `/_trilha/events`, which is an SSE connection. ## I changed `public/` and nothing happened in production In production `public/` is embedded in the binary. Run `trilha build` again. In development the folder is read from disk and the change shows up immediately. ## The CLI speaks Portuguese (or English) and I want the other one The CLI follows `TRILHA_LANG`, then `LC_ALL`, `LC_MESSAGES` and `LANG`. Set `TRILHA_LANG=en` or `TRILHA_LANG=pt` to force a language; anything that does not start with `pt` means English. --- # Overview Source: /trilha/reference Trilha's packages and what each one does. | Package | Import | Role | |---|---|---| | `trilha` | `github.com/emersonjoe/trilha` | runtime: `App`, `Ctx`, errors, CSRF, static files, export | | `h` | `github.com/emersonjoe/trilha/h` | HTML DSL | | `tmpl` | `github.com/emersonjoe/trilha/tmpl` | adapter for `html/template` | | `cache` | `github.com/emersonjoe/trilha/cache` | cache with expiry, tags and per-request memo | | `ui` | `github.com/emersonjoe/trilha/ui` | component kit (theme compatible with shadcn/ui) | | `ai` | `github.com/emersonjoe/trilha/ai` | OpenAI-compatible client, tools, agents | | `ai/mcp` | `github.com/emersonjoe/trilha/ai/mcp` | MCP client and server | | CLI | `github.com/emersonjoe/trilha/cmd/trilha` | `new`, `gen`, `dev`, `build`, `routes`, `export`, `audit`, `ui` | None of them depends on anything outside the standard library. Compatible with Go 1.22 or newer. ## Mental model on one page - **File conventions** in `app/` define routes, layouts and middlewares ([Conventions](/trilha/reference/conventions)). - Every route function receives `*trilha.Ctx` ([Ctx](/trilha/reference/ctx)) and returns `error` or `(h.Node, error)`. - Errors are values with HTTP meaning ([Errors](/trilha/reference/errors)). - Form and JSON input is filled and checked by `Bind` ([Validation](/trilha/reference/validation)). - HTML is an `h.Node` ([h](/trilha/reference/h)), coming from the DSL or from a template ([tmpl](/trilha/reference/tmpl)). - `trilha_gen.go` wires everything and is generated by the [CLI](/trilha/reference/cli); `App` ([App](/trilha/reference/app)) is what it builds. ## Stability Version 0.x: the API may change between minor versions. Breaking changes are listed in the repository's `CHANGELOG.md` with migration instructions. What "the API" means is written down. The exported symbols of the packages in the table above are covered by the promise; `internal/`, the exact output of the CLI and the HTML the `ui` components produce are not. Before a covered symbol disappears it gets a `Deprecated:` note saying what replaces it, a line in the CHANGELOG, and at least one minor version living alongside the replacement. The whole surface is versioned in [`api/current.txt`](https://github.com/emersonjoe/trilha/blob/main/api/current.txt), one line per symbol, and a test fails when it changes — so a removal shows up in the review instead of in your build. The rules are in [`API.md`](https://github.com/emersonjoe/trilha/blob/main/API.md). --- # File conventions Source: /trilha/reference/conventions Complete table of what each file and folder name in app/ means. ## Files | File | Exported function | Signature | Scope | |---|---|---|---| | `page.go` | `Page` | `func(c *trilha.Ctx) (h.Node, error)` | the folder's GET route | | `page.go` | `POST`, `PUT`, `PATCH`, `DELETE` (optional) | `func(c *trilha.Ctx) error` | forms; CSRF required | | `route.go` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS` (at least one) | `func(c *trilha.Ctx) error` | the folder's JSON API | | `kind.go`, or any file (optional) | `Kind` | `var Kind = trilha.KindPage` or `KindAPI` | subtree: how errors are rendered and whether CSRF applies (see [Errors](/trilha/reference/errors)) | | `route.go` (optional) | `CORS` | `var CORS = trilha.CORS{...}` | cross-origin policy of this route alone, preflight included | | `layout.go` | `Layout` | `func(c *trilha.Ctx, children h.Node) (h.Node, error)` | subtree | | `middleware.go` | `Middleware` | `func(c *trilha.Ctx, next trilha.Next) error` | subtree | | `middleware.go` (optional) | `MiddlewareGET`, `MiddlewarePOST`, `MiddlewarePUT`, `MiddlewarePATCH`, `MiddlewareDELETE`, `MiddlewareOPTIONS` | `func(c *trilha.Ctx, next trilha.Next) error` | subtree, that method only | | `not_found.go` (root only) | `NotFound` | `func(c *trilha.Ctx) (h.Node, error)` | the app's 404 | | `error.go` (root only) | `Error` | `func(c *trilha.Ctx, err error) (h.Node, error)` | every error status but 404 | | `setup.go` (root only) | `Setup` | `func(a *trilha.App) error` | before serving | | `setup.go` (optional) | `Config` | `func(cfg *trilha.Config)` or `func(cfg *trilha.Config) error` | before `trilha.New`; an error stops the boot | | `setup.go` (optional) | `Shutdown` | `func(a *trilha.App) error` | after the app stops accepting requests (close pool, queue, flush logs) | `page.go` and `route.go` in the same folder is an error. The function may live in any file of the package; the file name is what binds the convention. ### Kind follows the subtree `Kind` is a variable, not a function, and it is inherited like `Layout` and `Middleware`: declared in the package of a folder, it decides that folder and everything below it, and the deepest declaration wins. `kind.go` is the file name for a folder that has no `route.go` of its own — a subtree root has to be able to speak without owning a route: ```go // app/painel/kind.go — this branch is browser pages, so its writes enforce CSRF package painel var Kind = trilha.KindPage ``` This matters more than error rendering: **`Kind` is what turns CSRF on**. A `route.go` is an API by default, and an API does not check the token, so the same form action moved from a `page.go` into a `route.go` starts accepting a POST from another site — silently. One line at the root of the branch covers every leaf, including the leaf someone adds next month. `trilha audit` reports a write route that no `Kind` reaches in an app that also serves pages. A `page.go` route is a page whatever the branch above it says: an inherited `KindAPI` never turns a page into JSON. ## Folders | Name | Becomes | Example | |---|---|---| | `events` | literal segment | `/events` | | `slug_` | parameter `{slug}` | `/events/{slug}` → `c.Param("slug")` | | `path__` | catch-all `{path...}`; must be a leaf | `/docs/{path...}` | | `organizer-` | route group; not part of the URL | layout/middleware for the subtree | | `app.css`, `robots.txt` | fixed path with an extension (dot in the middle of the name) | `/app.css`, `/manifest.webmanifest`, `/sw.js` | | `.well-known` | the one dot folder that is a route | `/.well-known/security.txt` | | `_x`, `.x`, `testdata` | ignored | — | A folder with a dot in its name serves a fixed path with an extension. Since `app.css` is not a Go identifier, declare another package name in the file (`package appcss`); the generator imports everything with an alias, so the package name does not matter. Folders that **start** with a dot stay ignored, with a single exception: `.well-known`, where RFC 8414, RFC 9728, RFC 8555, RFC 9116 and OpenID Discovery publish their documents. Inside it the conventions are the usual ones — `app/.well-known/security.txt/route.go` answers `/.well-known/security.txt`. A `page.go` or `route.go` inside any *other* dot folder is now an `E_HIDDEN_ROUTE` error instead of a 404 nobody can explain; to park a folder out of the routing on purpose, start its name with `_`. The Go tool does not match a path with a dot in `./...`, so `go vet ./...` and `go test ./...` skip that package as a target. It still compiles: `trilha_gen.go` imports it by its explicit path. ## Cross-origin on one route `Config.CORS` is the policy of the whole app. When only a few paths are public — the discovery documents under `/.well-known/`, fetched from another origin by a client that has no session yet — the route carries its own: ```go package oauthresource // Only this route. The other routes stay same-origin. var CORS = trilha.CORS{Origins: []string{"*"}, Methods: []string{"GET"}} func GET(c *trilha.Ctx) error { ... } ``` The framework answers the preflight from the policy (204 with `Access-Control-Allow-*`, or 403 for an origin or method that is not on the list) and adds the headers to every response of that route. A route that declares its own policy decides alone: the app-wide list neither widens nor narrows it. Writing `func OPTIONS` in the same file takes the preflight back — the common case is declarative, the odd one is still yours. `HEAD` is not a handler name: since Go 1.22 the router answers HEAD with the `GET` handler. Precedence: literal beats parameter, which beats catch-all. Two sibling dynamic folders are an error. Two folders producing the same URL (through groups) are an error. ## Other project folders | Folder | Role | |---|---| | `public/` | static files served at the root; embedded in the binary in production | | `trilha_gen.go` | generated; committed; never edited by hand; carries the package the folder declares (see [CLI](/trilha/reference/cli)) | | `.trilha/` | temporary binaries of `dev` and `export`; ignored by git | ## Execution order for `GET /a/b` ```text middleware(app) → middleware(app/a) → middleware(app/a/b) → middlewareGET(app) → middlewareGET(app/a) → middlewareGET(app/a/b) → Page (or method) → layout(app/a/b) → layout(app/a) → layout(app) ``` The per-method chain runs inside the route-wide one: a rule for a single method refines what the route already decided. For `POST` it is `MiddlewarePOST`, and so on; a method with no chain of its own just runs the route's. ## Generation errors | Code | Cause | |---|---| | `E_PAGE_AND_ROUTE` | `page.go` and `route.go` in the same folder | | `E_NO_PAGE_FUNC` | `page.go` without `Page` | | `E_NO_METHOD` | `route.go` without an exported method | | `E_NO_LAYOUT_FUNC`, `E_NO_MIDDLEWARE_FUNC`, `E_NO_NOT_FOUND_FUNC`, `E_NO_ERROR_FUNC`, `E_NO_SETUP_FUNC` | file without the expected function | | `E_AMBIGUOUS_SEGMENT` | two dynamic folders at the same level | | `E_CATCHALL_NOT_LEAF` | routes below an `x__` folder | | `E_BAD_SEGMENT` | invalid parameter name or dynamic group (`x_-`) | | `E_DUPLICATE_ROUTE` | two folders producing the same URL | | `E_UNUSED_METHOD_MIDDLEWARE` | `MiddlewareX` that reaches no route serving `X` | | `E_PARSE` | Go file that does not compile | | `E_NO_APP` | there is no `app/` folder | | `E_HIDDEN_ROUTE` | `page.go` or `route.go` inside a folder whose name starts with a dot | | `E_UNROUTABLE_METHOD` | `func HEAD`, `TRACE` or `CONNECT`: the router does not take those from a file | | `E_CORS_ON_PAGE` | `var CORS` in a `page.go` | --- # Ctx Source: /trilha/reference/ctx Everything a route function can do with the request context. `*trilha.Ctx` wraps the request and the response. It is created per request and must not be used by another goroutine after the handler returns. ## Request | Method | Description | |---|---| | `Request() *http.Request` | the original request | | `SetContext(ctx)` | replaces the request context: a middleware passes values to code that only receives `*http.Request` | | `SetRequest(*http.Request)` | replaces the request (rewritten URL, wrapped body) | | `Context() context.Context` | request context (cancellation) | | `Param(name) string` | route parameter (`slug_` → `"slug"`) | | `Pattern() string` | the template of the route that matched (`/blog/{slug}`), the aggregatable form of the path; `""` for what the fallback answered (static file, 404, trailing-slash redirect) | | `Query(name) string` | first value of the query parameter | | `Form(name) string` | form field (parses on demand, with a size limit) | | `FormErr() error` | form parse error: 400 invalid, 413 too large | | `BindJSON(&v) error` | decodes the JSON body; unknown fields are an error (400); 413 above the limit | | `Cookie(name) (*http.Cookie, error)` | request cookie | | `Accepts(offers...) string` | the offer the client prefers (`Accept`, ranked by `q`), or `""`; an absent or `*/*` header picks the first offer | | `RequestID() string` | received `X-Request-ID` or a generated id | | `Env() trilha.Env` | `trilha.Dev` or `trilha.Prod` | | `Base() string` | URL prefix (`TRILHA_BASE_PATH`), without trailing slash | | `App() *trilha.App` | the application | | `Fragment() string` | id the client wants to swap (`Trilha-Fragment` header), or `""` on a normal navigation ([Interactivity](/trilha/learn/interactivity)) | ## Response | Method | Description | |---|---| | `JSON(code, v) error` | writes JSON with the right `Content-Type` | | `Text(code, s) error` | writes plain text | | `HTML(code, node) error` | writes a node as a whole document, without layouts | | `Redirect(url) error` | returns the 303 redirect error (use with `return`) | | `Status(code)` | status the next page render will use | | `Header(k, v)` | sets a response header | | `SetCookie(*http.Cookie)` | adds `Set-Cookie` | | `Flash(kind, text)` | queues a message for the next request, in a signed cookie: the news the redirect would eat. `ui.Flashes(c)` shows it. On a fragment answer it travels in the `Trilha-Flash` header instead, and `ui.js` shows it. Without `TRILHA_SECRET` nothing is written and the app says so once in the log | | `Flashes() []Flash` | the messages left by the previous request plus the ones this one has not sent yet; reading them takes them, and reading twice gives the same list | | `Render(code, node) error` | writes the page **with the route's layouts** (like GET): for a `POST` to return the form with errors (422); on a fragment, without the layouts | | `Stream() *Stream` | Server-Sent Events response: `Send(event, data)`, `JSON(event, v)`, `Comment(s)`, `Done()`; disables the *write timeout* ([AI and agents](/trilha/learn/ai-and-agents)) | | `Writer() http.ResponseWriter` | direct access (long downloads, WebSocket) | | `Written() bool` | whether the response has started | ## HTTP cache | Method | Description | |---|---| | `ETag(tag) bool` | writes `ETag` (quoting it if needed) and reports whether the request already had it | | `LastModified(t) bool` | writes `Last-Modified` and reports whether the copy is current | | `CacheControl(v)` | writes `Cache-Control` verbatim | `true` means the `304` is already written: return `nil, nil` and write nothing else. Only `GET` and `HEAD` answer `304`; on other methods the headers are written and the answer is always `false`. An empty tag or a zero date writes nothing. When both are declared, `If-None-Match` decides and the date stays as metadata, as RFC 9110 asks. Files under `static/` already carry an ETag: the content fingerprint that goes in `?v=`. ## Between page and layout | Method | Description | |---|---| | `SetTitle(s)` / `Title() string` | page title, read by layouts | | `Set(key, v)` / `Get(key) any` | per-request values (middleware → page → layout) | ## Islands ```go func (c *Ctx) Island(src string, props any, children ...h.Node) h.Node ``` Renders `<div data-trilha-island="…" data-trilha-props="…">` with the children as the server-rendered fallback. `src` is a module in `public/` (addressed through `Asset`, so it carries the content hash) whose **default export** is the mount function, called once with `(el, props)`. `props` is anything `encoding/json` serializes, or `nil`; it travels as an escaped attribute and is read back with `JSON.parse`, so it is data and never markup. Props that do not serialize warn once and leave the fallback alone. The loader is a single inline script with the request nonce, emitted with the first island of the response ([Interactivity](/trilha/learn/interactivity)). ## Long connections and large bodies | Method | Description | |---|---| | `AllowBody(n int64)` | body limit for **this** request, in place of `Config.MaxBodyBytes` | | `NoReadDeadline() error` | drops this request's read deadline (a slow upload is not an error) | | `NoWriteDeadline() error` | drops the write deadline (long download, SSE) | | `Hijack() (net.Conn, *bufio.ReadWriter, error)` | takes the connection over: deadlines cleared, and Trilha writes nothing more on it | The default limit belongs to the app; the exception belongs to the route. Raise it in the route's `middleware.go`, not in the handler — form CSRF reads the body before the handler runs, so the decision has to come first: ```go // app/anexos/middleware.go func Middleware(c *trilha.Ctx, next trilha.Next) error { if c.Request().Method == "POST" { c.AllowBody(8 << 20) // this request only; every other route keeps the app's limit c.NoReadDeadline() } return next() } ``` Going over the limit is still a 413 with the usual message, through `FormErr`, `Bind*` or a direct read of `Request().Body`. ### WebSocket Trilha has no WebSocket of its own, and that is a decision. The protocol is transport: it touches no route, no layout and no render. What it does need — fragmentation and continuation frames, control frames interleaved with a message, the close handshake with a deadline, UTF-8 validation, masking, size limits, concurrent writes, backpressure, `permessage-deflate` — is a few hundred lines that the Autobahn suite tests in 500+ cases. The asymmetry decides it: your app can add `coder/websocket` to **its** go.mod (principle II binds the framework, not the app), but it cannot take those lines out of the framework. What was missing was the door, and `Hijack` is it: ```go func WS(c *trilha.Ctx) error { conn, _, err := c.Hijack() // read and write deadlines already cleared if err != nil { return err } defer conn.Close() return meuWebsocket.Serve(conn) // coder/websocket, gorilla, whatever you picked } ``` After `Hijack` the connection is yours: the framework writes no header, no error page and no body on it, and the access log records 101. ## Security | Method | Description | |---|---| | `CSRFToken() string` | the request's token; creates the cookie on the first call | | `trilha.CSRFInput(c) h.Node` | `<input type="hidden" name="_csrf">` for forms | | `trilha.CSRFTokenFrom(r) string` | the same token, for a renderer that only receives the `*http.Request` (`html/template`, `templ`, a handler of your own); `""` outside a Trilha request | | `trilha.NonceFrom(r) string` | the CSP nonce of the request, same reason and same rule ([Security](/trilha/reference/security)) | The token is verified automatically on `POST`, `PUT`, `PATCH` and `DELETE` of `page.go` (and of `route.go` if `Config.CSRFForAPI` is on), through the `_csrf` field or the `X-CSRF-Token` header. ## Bind `Bind(v any) error` fills a struct from the form (or from JSON, when the `Content-Type` is `application/json`). Fields match by the `form:"name"` tag (or by the field name); types: `string`, `[]string`, `bool` (`on`/`true`/`1`), `int`, `int64`, `float64` (comma or dot), `time.Time` (`2006-01-02` or `2006-01-02T15:04`) and pointers (nil when absent). A nested struct is flattened, with the tag as prefix (`Billing Address `+"`form:\"bill_\"`"+` reads `bill_zip`…). Values that do not convert become `FieldErrors` (message `trilha.BindInvalid`, adjustable) after every field has been tried. The `validate:"..."` tag of each field is applied right after, in the same pass: see [Validation](/trilha/reference/validation). ## File `File(field string, rules FileRules) (*Upload, error)` reads one file from a multipart form and only answers with it if it passes the rules. | Symbol | Role | |---|---| | `FileRules.MaxSize int64` | limit for this file, apart from `Config.MaxBodyBytes`; 0 leaves the body limit doing the work | | `FileRules.Accept []string` | media types allowed, matched against the **detected** type: `"image/png"`, `"image/*"`, `"*/*"`; empty accepts anything | | `FileRules.Optional bool` | an absent field returns `(nil, nil)` instead of an error | | `Upload.Name` | sanitised name: no directory, no separator, no control character, at most 100 characters, never empty | | `Upload.MIME` / `Upload.Ext` | type detected in the first 512 bytes, and the extension that matches it | | `Upload.Size` / `Upload.File` | size in bytes, and the file itself positioned at the start | | `up.Save(dir) (string, error)` | writes inside `dir` (mode 0600) under a free name and returns the path | | `up.Close() error` | closes the file | A rule that fails is `FieldErrors` under the field's name, like `Bind`; anything else (a broken body, a full disk) comes back as itself. Messages come from `ValidationMessages` (`required`, `filemax`, `filetype`) — see [Validation](/trilha/reference/validation). --- # Package h Source: /trilha/reference/h Reference for the HTML DSL: nodes, elements, attributes and control flow. ```go import "github.com/emersonjoe/trilha/h" ``` ## The Node type ```go type Node interface { Render(w io.Writer) error } ``` Any value with that method can be a child of an element. `h.Render(n) (string, error)` is the convenience for tests. ## Text and structure | Function | Output | |---|---| | `Text(s)` | escaped text | | `Textf(fmt, a...)` | formatted and escaped text | | `Raw(html)` | unescaped HTML — the only door | | `Fragment(children...)`, `Group(...)` | children in sequence, without a wrapping element | | `Doctype()` | `<!doctype html>` | | `Nil` | empty node | | `El(tag, children...)` | element with an arbitrary tag | | `Void(tag, attrs...)` | arbitrary void element | ## Control flow | Function | Behavior | |---|---| | `If(cond, n)` | `n` if true, empty if false | | `IfElse(cond, a, b)` | `a` or `b` | | `Map(items, f)` | `f(item)` for each item | | `MapIndex(items, f)` | `f(i, item)` | `nil` as a child is ignored. ## Elements Every commonly used HTML element has a function with a capitalized name: `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`, among others. Void elements (`Br`, `Img`, `Input`, `Meta`, `Link`, `Hr`, `Source`, `Track`, `Wbr`, `Area`, `Col`, `Embed`, `Base`) accept only attributes. ## Attributes | Function | Attribute | |---|---| | `Attr(name, value)` | any attribute, escaped value | | `Bool(name)` | boolean attribute | | `Class(v...)` | `class`, joined with spaces and skipping empty values | | `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` | the attribute of the same name | | `StyleAttr`, `TitleAttr`, `LabelAttr` | `style`, `title`, `label` (the names without the suffix are elements) | | `Data(key, v)`, `Aria(key, v)` | `data-key`, `aria-key` | | `Attrs(...)` | several attributes as one node, for a component that sets more than one on the element it is placed in (`ui.Confirm`); non-attributes are dropped | | `Disabled()`, `Checked()`, `Selected()`, `Required()`, `Autofocus()`, `Hidden()`, `Readonly()`, `Multiple()`, `Open()`, `Defer()`, `Async()`, `Autoplay()`, `Controls()`, `Novalidate()` | booleans | Attributes may appear at any position among the children; they are written in the opening tag, in the order they appear. --- # Package tmpl Source: /trilha/reference/tmpl Use html/template inside the pages and layouts pipeline. ```go import "github.com/emersonjoe/trilha/tmpl" ``` | Function | Description | |---|---| | `Node(t *template.Template, name string, data any) h.Node` | node that executes the named template; an execution error becomes a render error (500), with no partial output | | `Must(fsys fs.FS, patterns ...string) *template.Template` | `template.ParseFS` that panics on error; call it at package level to fail at startup | | `Wrap(t *template.Template, name, slot string) *Shell` | prepares a shell template to receive an `h.Node` where it calls `{{template slot .}}`; call it at package level — it clones the set, and `html/template` only clones a set that has not executed yet | | `(*Shell) Node(data any, children h.Node) h.Node` | renders the shell with `data` and `children` in the slot; a shell that never reaches the slot is a render error | | `HTML(n h.Node) (template.HTML, error)` | the node as `template.HTML`, for data given to a template the app executes itself | ## Usage ```go //go:embed *.html var files embed.FS var t = tmpl.Must(files, "*.html") func Page(c *trilha.Ctx) (h.Node, error) { c.SetTitle("Report") return tmpl.Node(t, "report", data), nil } ``` The template uses `{{define "report"}}...{{end}}` and receives `data` as `.`. Escaping is the contextual escaping of `html/template`. The node can be combined with the DSL: `h.Section(h.Class("x"), tmpl.Node(t, "part", d))`. ## The other direction: an h.Node inside a template The shell of an app that already exists in `html/template`, with the new screens written in `h`, is the halfway house of every migration. `Wrap` ties the two together: ```go var shell = tmpl.Wrap(tmpl.Must(files, "*.html"), "shell", "content") func Layout(c *trilha.Ctx, children h.Node) (h.Node, error) { return shell.Node(page(c.Request()), children), nil } ``` The template keeps the shape it had — the slot is the `{{template "content" .}}` that was already there — and nothing in the app converts anything to `template.HTML`: what `h` rendered was escaped on the way in, and `tmpl` is the single place that says so. The data of the shell can be built from the `*http.Request` alone, including [`trilha.NonceFrom(r)` and `trilha.CSRFTokenFrom(r)`](/trilha/reference/ctx). `examples/blog` has the whole thing in `app/legado-`. `Wrap` clones the set, so call it at package level: `html/template` refuses to clone a set that has already executed. A shell that never reaches the slot — a `{{if}}` that hid it, the wrong slot name — fails the render with `tmpl: template %q never rendered the slot` instead of quietly answering a page with no content. `HTML(n)` is the low-level way out, for a template the app executes itself. --- # Errors Source: /trilha/reference/errors The error values Trilha understands and how each one becomes a response. Handlers return `error`. Trilha translates: | Value | Page (`page.go`) | API (`route.go`) | |---|---|---| | `nil` | response written by the handler; 204 if nothing was written | same | | `trilha.ErrNotFound` (or an error wrapping it) | 404 with `not_found.go` | 404 `problem+json`, `"title":"Not Found"` | | `*trilha.RedirectError` via `trilha.Redirect(url)` (303) or `trilha.RedirectCode(url, code)` | redirect | redirect | | `*trilha.HTTPError` via `trilha.Errorf(code, fmt, a...)` | the status, with `error.go` (4xx) | the status, with the message in `detail` (4xx) | | any other `error` | 500 with `error.go`; details only in dev | 500, `detail` only in dev | | `*trilha.Problem` | the status, with `error.go` | the problem, as it was written | | `panic` in the handler | recovered and handled as 500; stack only in dev | same | ### Page or problem+json? The column is decided per route; the `Accept` header is the tie-breaker, ranked by `q`: - `page.go` → always a page. A fragment swapped into the page needs HTML even when the `fetch` says otherwise. - `route.go` → `problem+json`, **except** when `Accept` prefers `text/html` over `application/json` — a browser in the address bar. The path plays no part: a `route.go` under `/api/` shows the error page to a browser just like any other. - An absent `Accept`, or `*/*` (`fetch`, `curl`), is not a preference: the kind of the route decides. - `var Kind = trilha.KindPage` (always a page, and CSRF required on `POST`/`PUT`/`PATCH`/`DELETE`) or `trilha.KindAPI` (always `problem+json`, whatever `Accept` says) pins the behaviour. It is inherited by the whole subtree, so a `kind.go` at the root of a branch decides every `route.go` below it; see [File conventions](/trilha/reference/conventions#kind-follows-the-subtree). - With no route at all (404), there is no kind to ask: `Accept` decides, and when it is silent the `/api/` prefix is the last resort. ### One page for every status but 404 `app/error.go` answers **every** error status, not only the 5xx: a 403 in an app with roles is the most common answer after 200, and it deserves the app's menu, wording and layout. `app/not_found.go` keeps the 404 — it exists and it is the place. The signature does not change; the status comes from the error: ```go func Error(c *trilha.Ctx, err error) (h.Node, error) { switch trilha.StatusOf(err) { case http.StatusForbidden: return panel.Denied(c), nil default: return panel.Broke(c), nil } } ``` `trilha.StatusOf(err)` reports the status the framework will send — the same classification the table above describes. (`c.Status` is a setter; the page receives the error, not the code, which is why the function exists.) The framework's own page stays as the net, with the text it always had: for an app with no `error.go`, and for an `error.go` that itself fails. API routes (`KindAPI`) are untouched: `problem+json` as before. ### Answering on your own `not_found.go`, `error.go` and `page.go` may write the whole response and return `(nil, nil)`: Trilha adds nothing on top. It serves a plain-text 404 (`http.NotFound(c.Writer(), c.Request())`), another `Content-Type` or another status. If the function returns `nil` **without** writing, the framework's simple page applies (404/500); in `page.go`, 204. `HTTPError` messages with a 5xx code are never shown to the client. Every 5xx error goes to the log with the `request_id`. ```go if ev, ok := events.Find(slug); !ok { return trilha.ErrNotFound } if seats < 0 { return trilha.Errorf(422, "seats cannot be negative") } return c.Redirect("/events/" + ev.Slug) ``` Errors from `c.BindJSON` and `c.FormErr` are already `HTTPError` (400 or 413): just return them. ## Problem API errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details, sent as `application/problem+json`: ```json {"type":"about:blank","title":"Unprocessable Entity","status":422, "instance":"/api/posts","request_id":"01J…","fields":{"title":"required"}} ``` Return a `*trilha.Problem` to say more than a status: ```go return &trilha.Problem{ Type: "https://example.com/probs/out-of-credit", Title: "Out of credit", Status: http.StatusPaymentRequired, Detail: "The account has $3 and the operation costs $10.", Extra: map[string]any{"balance": 300}, } ``` | Field | Role | |---|---| | `Type` | URI naming the kind of problem; default `about:blank` | | `Title` | short summary, the same for every occurrence; default the status text | | `Status` | HTTP status | | `Detail` | what happened **this** time; read by a person | | `Instance` | this occurrence; default the request path | | `Fields` | the `FieldErrors` of a 422 | | `Extra` | extension members, written at the top level (`balance` above) | `trilha.ProblemType` (a `func(status int) string`) fills `Type` for every problem that does not set one — for an app that documents its errors at a URL of its own. In production a 5xx never carries `Detail`, and the message goes to the log with the `request_id`; in `Dev` it comes in the response. A `Detail` **you** wrote is yours and is always sent: the rule is about what the framework would leak, not about what you decided to say. ## Content negotiation `c.Accepts(offers...)` returns the offer the client prefers, ranked by the `q` values in `Accept`, or `""` when it accepts none of them. An absent or `*/*` `Accept` is not a preference, so put your default first: ```go switch c.Accepts("text/html", "application/json") { case "application/json": return c.JSON(200, ev) default: return c.Render(200, page(ev)) } ``` ## FieldErrors `trilha.FieldErrors` is a `map[string]string` (field → message) that implements `error`. Returned from a handler it answers **422**: JSON with `"fields"` in API routes, an error page in pages. A form usually does not return it: it validates and, on error, calls `c.Render(422, …)` showing each message in its field (`ui.Errors`, `ui.InvalidIf`). | Method | Role | |---|---| | `Add(field, msg)` | records (the first message for a field wins) | | `Has(field) bool`, `Get(field) string` | lookup | | `Any() bool` | are there errors? | | `OrNil() error` | `nil` when empty, for `return errs.OrNil()` | --- # Validation Source: /trilha/reference/validation The validate tag, the rules per type, your own rules and the messages Bind returns. `Bind` validates while it fills: after converting the values, it applies the `validate` tag of each field and returns `FieldErrors` (field → message) with everything that failed. The same rules run for a form and for JSON — with a JSON body the field is named by its `json` tag, which is the name the client recognises. ```go type entry struct { Name string `form:"name" validate:"required,min=3,max=80"` Email string `form:"email" validate:"required,email"` Confirm string `form:"confirm" validate:"eqfield=email"` Date time.Time `form:"date" validate:"required,min=2026-01-01"` Plan string `form:"plan" validate:"oneof=free pro"` Discount *int `form:"discount" validate:"required,min=0"` } ``` ## Rules | Rule | Text | Number | `time.Time` | `[]string` (checkbox, select) | |---|---|---|---|---| | `required` | not empty | any value, including `0` only through a pointer | not the zero date | at least one | | `min=n` | at least `n` characters | value `>= n` | date is not before `n` (`2006-01-02`) | at least `n` chosen | | `max=n` | at most `n` characters | value `<= n` | date is not after `n` | at most `n` chosen | | `len=n` | exactly `n` characters | — | — | exactly `n` chosen | | `email` | one `@`, a domain with a dot | — | — | — | | `url` | absolute `http`/`https` | — | — | — | | `oneof=a b c` | value is one of the options, separated by spaces | same, as text | — | — | | `eqfield=other` | equal to the other field's value, by form name | same | same | — | Rules are separated by commas and applied in order; the first one to fail is the message for that field. Every rule but `required` ignores an empty value, so an optional field only answers for what somebody typed. A value that does not even convert (`abc` in an `int`) gets `trilha.BindInvalid` and no rule message — one message per field. **`required` is the zero value**: `0`, `false`, `""` and the zero date do not pass. Where zero is a real answer, declare the field as a pointer: a `*int` that arrived holding `0` is present, and only an absent field fails. ## Your own rules | Symbol | Role | |---|---| | `trilha.Validator` | `interface{ Validate() error }`: the value checks itself | | `trilha.AddRule(name, func(Field) bool)` | registers a name for the tag; panics if the name exists | | `trilha.Field` | what a rule sees: `Name`, `Param`, `Text`, `Value`, `Other(name)` | | `trilha.ValidationMessages` | `map[string]string` of the messages; `{param}` is replaced | | `trilha.UseValidationPTBR()` | switches the messages, `BindInvalid` included, to Portuguese | A field whose **type** has `Validate() error` is checked after the tag rules pass, with the error message going to `FieldErrors` as it is (both a value and a pointer receiver work). The **struct** may have `Validate() error` too: it runs at the end, only when no field failed — which is what makes a check that reads two fields safe. It may return `FieldErrors` to say which field is at fault; any other error comes back from `Bind` untouched. ```go trilha.AddRule("cep", func(f trilha.Field) bool { return validZIP(f.Text) }) trilha.ValidationMessages["cep"] = "invalid ZIP code" ``` `Field.Value` is the converted value (`string`, `bool`, `int64`, `float64`, `time.Time`, `[]string`, or `nil` when the field was not sent) and `Field.Text` is the same thing as text, which is all most rules need. A rule that compares fields reads the other one with `f.Other("email")`. ## Where validation stops The tag says what a **value** accepts. Whether an account exists, whether the room is free that night, whether this person may do this — those read your data and belong to your package. Run them after `Bind` and merge into the same `FieldErrors`, so every message reaches the person in one response: ```go errs := trilha.FieldErrors{} if err := c.Bind(&in); err != nil { fe, ok := err.(trilha.FieldErrors) if !ok { return err } errs = fe } for field, msg := range plan.Check(&in) { errs.Add(field, msg) } if errs.Any() { return c.Render(http.StatusUnprocessableEntity, page(c, in, errs)) } ``` Unknown rule names panic on the first request that hits the field, on purpose: a typo in a tag would otherwise be a form that accepts anything in production. --- # App and Config Source: /trilha/reference/app What the generated file builds and what you can adjust in setup.go. ## Config ```go type Config struct { Addr string // ":3000"; PORT/ADDR in the environment Env Env // Dev | Prod; TRILHA_ENV MaxBodyBytes int64 // 1 MiB Logger *slog.Logger // slog.Default() Public fs.FS // static files; nil turns them off Mounts map[string]fs.FS // static trees by URL prefix, before Public CSRFForAPI bool // require CSRF in route.go too CSRF CSRF // cookie, field and header names of the token BasePath string // URL prefix; TRILHA_BASE_PATH Security Security // headers (see Security) TrustedProxies []string // CIDRs; TRILHA_TRUSTED_PROXIES RateLimit RateLimit // global per-client limit Secret, PreviousSecret []byte // TRILHA_SECRET, TRILHA_SECRET_PREVIOUS Timeouts Timeouts // http.Server limits (trilha.NoTimeout disables one) StaticCacheControl string // Cache-Control of static files in prod ("public, max-age=3600") StaticHeaders func(name string, hdr http.Header) // headers per static file LogRequest func(c *Ctx, status int, dur time.Duration) bool // nil logs every request OnSecurityEvent func(SecurityEvent) DevReload string // trilha.Off disables the reload script in dev; TRILHA_DEV_RELOAD=off Observability Observability // health probes and the metrics endpoint CORS CORS // origins allowed to call the app (zero value = off) } ``` `trilha.ConfigFromEnv()` reads the variables; `trilha.PublicFS(embedded, "public")` picks between the embedded copy (prod) and the folder on disk (dev). ### Where to configure The generated file does `cfg := trilha.ConfigFromEnv()`, calls `app.Config(&cfg)` if `app/setup.go` exports `func Config(cfg *trilha.Config)`, and then `trilha.New(cfg)` and `app.Setup(a)`. `Config` may also be written as `func Config(cfg *trilha.Config) error`, and then the generated file stops the boot with your message — reading the app's own configuration is what fails on startup, and it should fail where it happens. You may change any field in either one; the only difference is *when* the value is read: | Fields | Read at | `Config` | `Setup` (via `a.Config()`) | |---|---|---|---| | `Security`, `Public`, `MaxBodyBytes`, `CSRFForAPI`, `BasePath`, `OnSecurityEvent`, `StaticCacheControl`, `StaticHeaders` | every request | ✓ | ✓ | | `Logger`, `Secret`/`PreviousSecret`, `RateLimit`, `TrustedProxies`, `CORS` | derived in `New` and **reapplied** when serving starts (`ListenAndServe`, `Handler`, `Export`) | ✓ | ✓ | | `Addr`, `Timeouts` | `ListenAndServe` | ✓ | ✓ | | `Env` | `New` (ephemeral key in dev) and per request | ✓ | partial | Use `Config` when you want to build the configuration from your own package (file, Vault, flags) instead of the environment. ### CSRF names The token travels under three names, and every one of them is a default, not a rule: | Field | Default | |---|---| | `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"} ``` Rename them when the app is not alone on the page: mounted inside a server that already writes `_csrf`, two hidden fields with the same name reach the handler and the browser sends whichever cookie it likes. An empty field keeps its default, so renaming one is one line. The name given here is the one `CSRFInput`, `CSRFToken`, the check, the CORS allow-list and the test client all use — there is no second place to keep in step. ### CORS `CORS` is off while `Origins` is empty: no header is added, and `OPTIONS` keeps reaching the router. | Field | Meaning | |---|---| | `Origins []string` | exact origins (`https://app.example.com`), or the single entry `"*"` | | `Methods []string` | default `GET, HEAD, POST, PUT, PATCH, DELETE` | | `Headers []string` | what the client may send; default `Content-Type, Authorization, X-CSRF-Token, Trilha-Fragment` | | `Expose []string` | response headers the other origin's script may read | | `Credentials bool` | allows cookies and `Authorization`; incompatible with `"*"` | | `MaxAge time.Duration` | how long the browser caches the preflight; zero omits the header | An unsafe or malformed policy panics in `New` (`"*"` with `Credentials`, `"*"` mixed with other origins, an origin with a path, a trailing slash or no scheme). See [Security](/trilha/learn/security) for why. ### Timeouts `Timeouts.Shutdown` (5 s) is how long `ListenAndServe` waits for in-flight requests after `SIGINT`/`SIGTERM`. Zero means "default"; `trilha.NoTimeout` disables the limit (large uploads on a slow network, long polling). `Write` applies to the whole response: instead of disabling it globally, a streaming route should use `c.Stream()` (SSE) or `c.NoWriteDeadline()`. ```go func Config(cfg *trilha.Config) { cfg.Timeouts.Read = trilha.NoTimeout // 32 MB uploads from a phone } ``` ### Static files `StaticCacheControl` replaces the production `Cache-Control` (dev always sends `no-cache`). `StaticHeaders(name, headers)` runs afterwards, per file, and may change any header: ```go cfg.StaticCacheControl = "public, max-age=31536000, immutable" // safe with 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") } ``` ### Static trees outside `public/` `Public` serves one tree at the root, which requires the folders on disk to be shaped like the URLs. When they are not — an icon generator that writes elsewhere, a folder shared with another build — `Mounts` maps prefix to tree: ```go cfg.Mounts = map[string]fs.FS{ "/icons/": sub(embedded, "static/public/icons"), "/js/": sub(embedded, "static/js"), } ``` Mounts are tried before `Public`, longest prefix first; a prefix that matches without the file falls through to the next one and then to `Public`, so nothing has to be exhaustive. `StaticCacheControl`, `StaticHeaders` and `Asset` treat a mounted file like any other, and the `name` given to `StaticHeaders` is the one from the URL (`icons/icon-192.png`), which is what tells one mount from another. ### The request log Every request matched by a route is logged. An app that serves its own static files sees most of that volume say "a file was served with 200" — and a log nobody reads protects nobody. `LogRequest` decides, with the response already written: ```go cfg.LogRequest = func(c *trilha.Ctx, status int, _ time.Duration) bool { return status >= 400 || c.Pattern() != "" } ``` It also covers "do not log the health check" and "sample 1% of the traffic". Files served from `Public` or `Mounts` never went through this log. The record carries both addresses: `path` is the concrete one (`/v/cmtk…/budget`), for whoever is looking into a single case, and `route` is the template (`/v/{tripId}/budget`), for whoever is counting. An app with an id in the URL has one path per record and one route per screen, and rebuilding the second from the first with a regular expression outside the app is the cardinality problem this field exists to avoid. [`c.Pattern()`](/trilha/reference/ctx) is the same value inside the handler, and it is empty for what the fallback answered — which is what the example above uses to keep static files out of the log. ### Version in the address (`Asset`) ```go func (a *App) Asset(path string) string func (c *Ctx) Asset(path string) string // same thing; it is what the layout uses ``` `c.Asset("/site.css")` returns `/site.css?v=8f3a1c92`, where `v` is the FNV-1a hash of the file's content in `Config.Public` (prefixed with `BasePath`, like `c.Base()`). Since the address changes when the file changes, a deploy never leaves anyone with new HTML and old CSS — the browser asks for a URL it has never seen. A request whose `v` matches gets `public, max-age=31536000, immutable`, whatever the `StaticCacheControl`; a wrong or missing `v` falls under the normal rule, and in `dev` nothing is immutable. The file is read once in production; in `dev` a `Stat` decides whether to re-read it, so editing the CSS and refreshing the page is enough. A path that does not exist in `Public` comes back unversioned, with a warning in the log: a typo in the layout does not take the page down. `ui.Head` and the examples already use `Asset`. ## App | Method | Description | |---|---| | `New(cfg) *App` | creates the application | | `Register(Route)` | registers a route (called by the generated file) | | `SetRootLayout`, `SetNotFound`, `SetErrorPage` | wire the root files | | `trilha.Provide[T](a, v)` | files a dependency under its type (see "Dependencies") | | `trilha.Use[T](b) T` | reads it back, from a `*Ctx` or from the `*App` | | `Values() map[string]any` | global values set in `Setup`, by name and untyped | | `Logger() *slog.Logger` | the logger | | `Env() Env` | environment | | `Handler() http.Handler` | the root mux, for tests and for embedding in another server | | `ListenAndServe() error` | serves with graceful shutdown on SIGINT/SIGTERM; then runs the `OnShutdown` hooks | | `OnShutdown(func(*App) error)` | registers what to close on exit (pool, queue, flush); `setup.go` may export `Shutdown`, which the generated file registers | | `Routes() map[string][]string` | registered patterns and their methods | | `AddExportPath(paths...)` | extra paths for `Export`; a last segment with a dot exports as that file, not as `index.html` | | `ExportPaths() []string` | what `Export` will render | | `Export(dir) error` | writes the static site | | `BasePath() string` | URL prefix | | `Security() *Security` | headers, adjustable in `Setup` | | `Config() *Config` | the whole configuration, adjustable in `Setup` (see "Where to configure") | `trilha.Run(a)` is what the generated `main` calls: it exports if `TRILHA_EXPORT` is set, otherwise it serves. `trilha.Fatal(err)` logs and exits, ignoring `http.ErrServerClosed`. ### Dependencies A page needs the store, the pool, the mailer. Keeping them in package variables works right up to the day there are two apps in one process — a host that mounts two of them, or a test that builds a second one — and then both read the same globals and the second test to run sees the first one's data. ```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` files the value under its type; `Use[T]` reads it back, and takes either the `*Ctx` of a handler or the `*App` itself — which is what `Setup` and a test have in hand. A type nobody provided panics at the call, naming the type, instead of turning up later as a nil somewhere else. The type is the key, so a seam is declared by writing it out: `trilha.Provide[Mailer](a, SMTPMailer{...})` files an interface, and the handler asking `Use[Mailer](c)` never learns which implementation it got. Without the type argument the key would be `SMTPMailer`, and the handler would be asking for something else. `Values()` is still there for glue by name, and `c.Get`/`c.Set` are the per-request values a middleware leaves behind — a different question, answered in [Middleware](/trilha/learn/middleware). ### Your own `main` If any file in the project's `main` package already declares `func main()`, the generator omits its own and writes only `newApp()`. You keep control of the lifecycle: ```go func main() { a := newApp() if err := migrate(a); err != nil { // between Setup and the server trilha.Fatal(err) } trilha.Run(a) } ``` `public/` is optional: the `//go:embed` is only generated when the folder has files. ### An app inside another binary When the folder declares a package other than `main`, the generated file follows it and exports the constructor: ```go // internal/crm/trilha_gen.go → package crm, func NewApp() *trilha.App mux := http.NewServeMux() mux.HandleFunc("/legacy", legacy.Handler) mux.Handle("/", crm.NewApp().Handler()) http.ListenAndServe(":8080", mux) ``` `Handler()` returns the `http.Handler` of the whole app — routing, static files, middlewares and error pages — so the host mounts it like any other handler. `trilha gen` needs nothing beyond the package the folder already declares; see [CLI](/trilha/reference/cli#an-app-inside-a-binary-that-already-exists). ## Testing an app The generated file defines `newApp()`, and `package trilha` ships the test client, so a test in the project's `main` package goes through the real app with no plumbing of its own: ```go func TestHome(t *testing.T) { trilha.TestRequest(t, newApp(), "GET", "/").WantStatus(200).WantContains("<h1>") } ``` | Symbol | Role | |---|---| | `TestingT` | `Helper()` and `Fatalf(...)`: what the helpers use from `*testing.T`, so the package never imports `testing` | | `TestRequest(t, a *App, method, target string, opts ...TestOption) *TestResponse` | one request against the whole app | | `TestRoute(t, r Route, method, target string, opts ...TestOption) *TestResponse` | one `route.go`, with its middlewares | | `TestPage(t, r Route, target string, opts ...TestOption) *TestResponse` | one page, with its layouts; `Node` comes filled in | | `NewTestClient(t, a *App) *TestClient` | the client with a cookie jar | | `(*TestClient) Request / Get / PostForm / PostJSON` | the requests | | `TestOption` | `WithApp`, `WithHeader`, `WithCookie`, `WithSigned`, `WithForm`, `WithJSON`, `WithBody`, `WithoutCSRF` | | `TestResponse` | `Node`, `WantStatus`, `WantContains`, `WantHeader`, `JSON(&v)`, `Cookie(name)`; embeds `*httptest.ResponseRecorder` | Every request carries the CSRF cookie and, on a method with a body, the matching `X-CSRF-Token` header: cookie and token come from the same client, which is exactly what double submit asks a browser for. `WithoutCSRF()` is how a test proves the refusal. No assertion returns an `error` — in a test, the value of an error is stopping with the right message, so a failure prints the target, the status and the body. Anything the ready-made assertions do not cover is an `if` over the embedded recorder. See [Testing](/trilha/learn/testing) for the whole trail. --- # Security Source: /trilha/reference/security Complete configuration of headers, proxies, rate limiting, signed cookies and events. ## Config.Security | Field | Default | Header | |---|---|---| | `CSP` | nonce policy (below) | `Content-Security-Policy` | | `CSPExtra map[string][]string` | — | adds origins to directives of the default policy | | `HSTS` | `max-age=31536000; includeSubDomains` (HTTPS only) | `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` in any field removes the header. `X-Content-Type-Options: nosniff` is always sent. Default policy: ```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()` returns the request's nonce; `trilha.NonceAttr(c)` puts it on an `h.Script`. `trilha.NonceFrom(r)` answers the same value to a renderer that only has the `*http.Request` — `html/template`, `templ`, a handler of your own — so the shell of an app being migrated does not need a middleware of its own to reach it. Adjust in `Setup` through `a.Security()`. ### When the response belongs to a host An app mounted inside a server that already answers for its own responses has two headers too many, not one: the host wrote the policy, and the app writes it again. | Field | Effect | |---|---| | `Delegated bool` | writes none of the headers — not the six that have an `Off`, and not the `nosniff` that has none | | `Nonce func(*http.Request) string` | the nonce comes from the host, one call per request that asks for it | ```go a.Security().Delegated = true a.Security().Nonce = func(r *http.Request) string { return host.NonceOf(r) } ``` `Delegated` is a decision, not a default: the zero value writes the headers, so a hand-written `Security{...}` never turns them off by omission. Boot records the delegation once in the log, because a response with no headers should be visible somewhere. Without `Nonce`, `c.Nonce()` invents a value per request, which is right for an app that publishes its own CSP and wrong for one that does not: the host's policy never heard of that nonce, and the browser refuses the script. With `Nonce` returning an empty string, `trilha.NonceAttr(c)` renders no attribute at all instead of `nonce=""`. ## Trusted proxies `Config.TrustedProxies []string` (CIDR or IP) or `TRILHA_TRUSTED_PROXIES=a,b`. Effects when the peer is trusted: `c.ClientIP()` reads `X-Forwarded-For` (the rightmost IP that is not a proxy), `X-Forwarded-Proto: https` turns on HSTS and marks cookies as `Secure`. ## Allowed hosts `Config.AllowedHosts []string` or `TRILHA_ALLOWED_HOSTS=a,b`. A request whose `Host` is not in the list is answered with 400 before the router, the probes and CORS, and emits a `host` event. Empty list = no check. | Pattern | Allows | Does not allow | |---|---|---| | `example.com` | `example.com`, `example.com:8443`, `EXAMPLE.com.` | `sub.example.com` | | `*.example.com` | `app.example.com` | `example.com`, `a.b.example.com` | In `Dev`, `localhost`, `127.0.0.1` and `::1` always pass. The value compared is the host the app receives — behind a proxy that rewrites `Host`, list what the proxy sends. ## Rate limiting `Config.RateLimit{RPS float64, Burst int}` applies a *token bucket* per `ClientIP` before the middlewares. `trilha.Limit(rps, burst) MiddlewareFunc` creates an independent limiter for a subtree. Response: 429 with `Retry-After` (seconds) and a `rate` event. `trilha.ErrRateLimited` may be returned by a handler for the same effect. ## Signed cookies | Symbol | Description | |---|---| | `c.SetSigned(name, value, ttl) error` | writes a `value|expires|hmac` cookie with `HttpOnly`, `SameSite=Lax`, `Secure` over HTTPS; `ErrNoSecret` without a key | | `c.Signed(name) (string, bool)` | reads and verifies signature and expiry | | `c.ClearCookie(name)` | expires a cookie | | `trilha.NewSigner(keys...)`, `Sign`, `Verify` | the signer (HMAC-SHA256) for direct use | | `Config.Secret`, `Config.PreviousSecret` | `TRILHA_SECRET`, `TRILHA_SECRET_PREVIOUS` (base64 or text, ≥ 32 bytes) | Without a secret: in `dev` an ephemeral key is generated (`trilha dev` keeps one per session); in `prod` the app warns in the log and `SetSigned` returns `ErrNoSecret`. ## Timeouts `Config.Timeouts{ReadHeader 10s, Read 30s, Write 60s, Idle 120s, MaxHeaderBytes 64 KiB}`. For long responses (SSE, download), call `c.NoWriteDeadline()` before writing. ## Security events ```go type SecurityEvent struct { Kind string // csrf | auth | body | host | rate | panic Status int Method string Path string IP string RequestID string } ``` Logged with `slog.Warn("security", ...)` and delivered to `Config.OnSecurityEvent`, once per request. ## `trilha audit` Checks: `TRILHA_SECRET`, `TRILHA_TRUSTED_PROXIES`, up-to-date `trilha_gen.go`, Go version, `.gitignore`, `go vet` and `govulncheck` (`--no-vuln` to skip). Exit code 1 with a critical item. A missing `TRILHA_SECRET` is critical only when the code signs something — `SetSigned`, `Signed`, a `Signer` of its own, `Config.Secret`, or the `auth` package. An app whose session is not Trilha's gets a warning instead: a secret that signs nothing still enters the `.env`, the deploy and the rotation, and the day somebody rotates it nothing happens, which is the worst thing a secret can teach. Set too short is critical either way — whoever set it meant to use it. It also warns about a write that no `Kind` reaches. A `route.go` is an API, and an API does not check the CSRF token, so a `POST` route in an app that also serves pages usually wants `var Kind = trilha.KindPage` in a `kind.go` above it — one line for the whole branch, see [File conventions](/trilha/reference/conventions#kind-follows-the-subtree). Setting `Config.CSRFForAPI` answers the same question the other way and silences the warning too. --- # Observability Source: /trilha/reference/observability Config.Observability, health endpoints, metrics registry, environment variables and the contract of each response. ## Config.Observability | Field | Default | What it does | |---|---|---| | `Health string` | `/_trilha/health` | base path of the probes; `trilha.Off` removes them | | `Metrics string` | `""` (off) | scrape path; empty registers no endpoint **and does not instrument requests** | | `Token string` | `TRILHA_OBS_TOKEN` | authorizes details and metrics; **at least 32 bytes**, compared in constant time | | `Trusted []string` | — | CIDRs (or IPs) that do not need the token | | `Details string` | automatic | `trilha.Off` never reveals details, not even to a token holder; empty = open in `dev`, authorized in `prod` | | `Timeout time.Duration` | 2 s | deadline of each check; `trilha.NoTimeout` disables it | | `CacheFor time.Duration` | 1 s | validity of the readiness result; `trilha.NoTimeout` disables the cache | Variables read by `ConfigFromEnv`: `TRILHA_OBS_TOKEN`, `TRILHA_METRICS`, `TRILHA_OBS_TRUSTED` (comma-separated list). ## Endpoints | Method and path | Response | Status | |---|---|---| | `GET /_trilha/health/live` | `application/health+json` | always 200 | | `GET /_trilha/health/ready` | same, runs the checks | 200 or 503 + `Retry-After: 5` | | `GET /_trilha/health` | same as `ready` | 200 or 503 | | `GET <Metrics>` | `text/plain; version=0.0.4` | 200, or 401 without authorization | All of them carry `Cache-Control: no-store`, `X-Robots-Tag: noindex` and `X-Content-Type-Options: nosniff`. Any other method returns 405 with `Allow: GET, HEAD`. The probes run **outside** the middleware chain: no CSRF, no layout, no rate limit (a liveness probe that got a 429 would kill a healthy process) and logged at `Debug` level, so they do not drown the audit log. ## Readiness checks ```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` always returns everything: it is for your code (an internal status page, a startup gate). The endpoint decides what to reveal. ## Metrics registry ```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` (a thousand by default) caps the label combinations per metric; the overflow falls into one series with every label set to `other` and a single warning in the log. | Type | Methods | |---|---| | `*Counter` | `Inc()`, `Add(v)`, `With(values...)` | | `*Gauge` | `Set(v)`, `Add(v)`, `Inc()`, `Dec()`, `With(values...)` | | `*Histogram` | `Observe(v)`, `With(values...)` | An invalid name (outside `[a-zA-Z_:][a-zA-Z0-9_:]*`) or the wrong number of label values causes a `panic`: it is a programming error, shows up on the first run and does not corrupt the output. Calling `Counter` twice with the same name returns the same series. `Histogram` with nil `buckets` uses the defaults, in seconds: 0.001 0.005 0.01 0.025 0.05 0.1 0.25 0.5 1 2.5 5 10. ## Framework metrics | Metric | Type | Labels | |---|---|---| | `trilha_requests_total` | counter | `method`, `route`, `status` | | `trilha_request_duration_seconds` | histogram | `method`, `route` | | `trilha_requests_in_flight` | gauge | — | | `trilha_security_events_total` | counter | `kind` (`csrf`, `auth`, `body`, `rate`, `panic`) | | `trilha_panics_total` | counter | — | | `go_goroutines`, `go_memstats_alloc_bytes`, `go_memstats_sys_bytes` | gauges | — | | `go_gc_cycles_total` | counter | — | | `trilha_uptime_seconds` | gauge | — | | `trilha_build_info` | gauge (always 1) | `version`, `go_version` | `route` is the registered pattern (`/blog/{slug}`). Static files, 404 and anything outside the router come in as `other`. ## Correlation ```go func (c *Ctx) RequestID() string // the client's X-Request-ID, or generated func (c *Ctx) TraceID() string // W3C traceparent; "" when absent or malformed func (c *Ctx) Log() *slog.Logger // logger with request_id and trace_id ``` A malformed `traceparent` is silently dropped: a value chosen by a third party does not enter the log as if it were a legitimate trace. ## What the audit checks `trilha audit` adds these items: token too short (critical), metrics configured without a token or a trusted network (critical), `0.0.0.0/0` in `Trusted` (warning) and no `a.Check(` anywhere in the project (warning). --- # auth Source: /trilha/reference/auth Provider, Options, Auth, User and Store — the API of the auth package, with the defaults and what each field changes. `import "github.com/emersonjoe/trilha/auth"` — OpenID Connect login with the standard library. The package registers no route: it exposes handlers that your `app/` publishes. ## Providers ```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 ``` | Constructor | Resulting issuer | Roles read from | |---|---|---| | `OIDC` | whatever you pass | `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` | the Frontend API URL, normalized (`https://<slug>.clerk.accounts.dev`) | `roles`, `groups` — Clerk's `id_token` carries the organization (`org_id`), not the role in it; a configured claim goes in `Options.RoleClaims` | `Provider.LogoutDomain` exists for Cognito: set it to the managed login domain (`<prefix>.auth.<region>.amazoncognito.com`, or your own) and `Logout` redirects to `/logout?client_id=…&logout_uri=…` there; the return URL must be in the app client's *Allowed sign-out URLs*. Left empty, `Logout` clears the local session, says so in the log and does not pretend it federated. Other providers ignore the field. Clerk publishes no `end_session_endpoint` either, and has no equivalent address: there `Logout` is always local, and the log says the Clerk session was left open. `Provider.HTTPClient` swaps the HTTP client (default: 10 s timeout). Discovery happens on first use and is valid for one hour; an issuer that differs between the configuration and the document is an error, not a warning. ## Options | Field | Default | What it does | |---|---|---| | `Scopes []string` | `openid profile email` | scopes requested from the provider | | `Absolute time.Duration` | 8 h | maximum session lifetime, counted from the login | | `Idle time.Duration` | 30 min | ends an idle session; `IdleOff: true` disables it | | `CookieName string` | `trilha_session` | session cookie name | | `LoginPath string` | `/entrar` | where `Require` sends an anonymous browser | | `AfterLogin string` | `/` | destination after the callback, when there is no `next` | | `AfterLogout string` | `/` | destination after the logout | | `RoleClaims []string` | — | additional claims to read roles from | | `Store Store` | `nil` | persists the session; `nil` = signed cookie, stateless | ## Auth ```go func New(p *Provider, o Options) *Auth // no network func (a *Auth) Start(c *trilha.Ctx) error // → provider (PKCE, state, nonce) func (a *Auth) Callback(c *trilha.Ctx) error // validates the callback and creates the session func (a *Auth) Logout(c *trilha.Ctx) error // deletes the session; RP-Initiated Logout when available 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 when anonymous func (a *Auth) Session(c *trilha.Ctx) (*User, error) ``` `Require` answers **302** to the login when the request is a navigation (Accept with `text/html`, outside `/api/`) and **401** otherwise. `RequireRole` answers **403** to someone authenticated without the role. **One** of the listed roles is enough; the comparison ignores case. ## User ```go type User struct { Subject string // sub: the stable identifier Email string // email, or preferred_username when there is none Name string Roles []string IssuedAt time.Time // moment of the login ExpiresAt time.Time Seen time.Time // last activity (idle window) SessionID string // changes on every 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 ``` With a `Store` the cookie carries only the identifier and the logout takes effect immediately for everyone. `MemoryStore` is for a single process: replicas do not share it, and a restart drops every session. For several replicas, implement the interface over your database or cache. ## Cookies | Cookie | Lifetime | Content | |---|---|---| | `trilha_oidc_state` | 10 min | `state` of the request in progress | | `trilha_oidc_nonce` | 10 min | `nonce` of the request in progress | | `trilha_oidc_verifier` | 10 min | PKCE verifier | | `trilha_oidc_next` | 10 min | destination after the login (relative path only) | | `trilha_session` | `Absolute` | the session (or its id, with a `Store`) | All are signed (they require `TRILHA_SECRET`), `HttpOnly`, `SameSite=Lax` and `Secure` under HTTPS. The four flow cookies are deleted on the callback, whether it succeeds or not. ## Accepted algorithms `RS256`, `RS384`, `RS512`, `ES256`, `ES384`. The list is fixed: the token's `alg` chooses nothing. RSA keys with a modulus smaller than 2048 bits are ignored in the JWKS, `kid` is required, and clock tolerance is 60 seconds. ## Audit `trilha audit` checks, when the project imports `trilha/auth`: client secret written in the code (critical) and `redirect_uri` over `http://` outside `localhost` (critical). --- # cache Source: /trilha/reference/cache Options, Key, Cache, Do, Get and Once — the API of the cache package, with the defaults and what each field changes. `import "github.com/emersonjoe/trilha/cache"` — in-memory cache with expiry, tags and bulk invalidation, plus a per-request memo. The package imports the runtime; the runtime does not import it, so an app that never mentions it carries nothing of it. ## Creating ```go func New(o Options) *Cache ``` | `Options` field | Default | What it does | |---|---|---| | `Name string` | `"cache"` | label on the metric series; give each cache its own | | `MaxEntries int` | `10000` | ceiling; the least recently used entry is evicted | | `Metrics *trilha.Metrics` | `nil` | registry to publish into, usually `a.Metrics()` | There is no `Close`: nothing runs in the background. An expired entry is removed when it is read or when the ceiling pushes it out, so a cache nobody touches costs nothing but the memory it already holds. ## Keys ```go type Key struct { Name string TTL time.Duration Tags []string } ``` `Name` is the address: equal names are the same entry, and everything that changes the answer belongs in it. `TTL` of zero or less means no expiry. `Tags` group entries for `Invalidate`; rewriting an entry replaces its tags rather than adding to them. ## Reading and writing ```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` and `Invalidate` return how many entries they removed. `Stats` carries `Hits`, `Misses`, `Evictions` and `Entries` — the same four numbers the metrics publish, for a health page or a test. Every method is safe from any goroutine. ## Typed access Go does not allow type parameters on methods, so the typed half of the package is package-level functions: ```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]` returns the value only when the stored type matches; a value written under another type is a miss, not a panic — a deploy that changes a struct must not crash the app. `Do` returns the cached value or produces it with `fn`, storing the result under `k`. An error is returned to the caller and cached for nobody. Only one `fn` runs per name at a time: whoever arrives while a fetch is in flight waits for it and reads the same answer, so the first request after an `Invalidate` does not become a stampede. The cache lock is not held while `fn` runs, so a `Do` inside a `Do` is fine. ## Per request ```go func Once[T any](c *trilha.Ctx, name string, fn func() (T, error)) (T, error) ``` Answers a question once per request and forgets it with the response. It is not a cache and takes no `*Cache`: use it for what a layout, a page and three components all need to know — the logged-in user, above all — instead of threading the value through every signature. The error is remembered too, so a failed lookup is attempted once. Storing a value that belongs to one user in the `*Cache` under a fixed name serves that value to the next visitor; `Once` is the one that cannot. ## Metrics With `Options.Metrics` set, four series appear in the exposition, labelled `cache` with the value of `Options.Name`: | Series | Type | Meaning | |---|---|---| | `trilha_cache_hits_total` | counter | reads answered from memory | | `trilha_cache_misses_total` | counter | reads that found nothing or found it expired | | `trilha_cache_evictions_total` | counter | entries dropped by the ceiling | | `trilha_cache_entries` | gauge | entries held right now | Evictions climbing steadily means `MaxEntries` is too low for the key space in use. --- # ui Source: /trilha/reference/ui The kit's components, variants, assets and the theme contract. `import "github.com/emersonjoe/trilha/ui"` — stdlib only. Components return `h.Node` with `ui-*` classes from `public/ui.css`; behaviors live in `public/ui.js`. ## Assets | Symbol | Role | |---|---| | `ui.Head(c) h.Node` | `<link>` for `ui.theme.css` and `ui.css`, inline script (with nonce) that applies the saved theme, `<script defer src=ui.js>`; honors `c.Base()` | | `ui.Body() h.Node` | `ui-body` class for the `<body>` | | `ui.Asset(name) []byte` | embedded content of `ui.css`, `ui.theme.css`, `ui.js`, `ui.nav.js` or `ui.upload.js` | | `ui.Files` | the five names, in the order `trilha ui` writes them | ## Variants and sizes `ui.Secondary()`, `ui.Outline()`, `ui.Ghost()`, `ui.Destructive()`, `ui.LinkStyle()`, `ui.Sm()`, `ui.Lg()`, `ui.IconSize()`. They are class attributes: valid on `Button`, `Submit`, `ButtonLink`, `Badge` and `Alert` (each one translates to its own class, e.g. `ui-btn-outline`, `ui-badge-outline`). ## Components | Function | Renders | |---|---| | `Container, Stack, Row, Grid, Spacer` | layout: max width, column, row, responsive grid | | `Header(children...)`, `Brand(href, name)`, `Nav(...)`, `NavLink(href, label, current)`, `Sidebar(...)` | sticky top bar, brand, navigation (with `aria-current`), side column | | `H1, H2, H3, Lead, Muted, Code(s), Kbd(s)` | typography | | `Button, Submit, ButtonLink(href, ...)` | `<button type=button>`, `<button type=submit>`, `<a>` styled as a button | | `Card, CardHeader, CardTitle(s), CardDescription(s), CardContent, CardFooter` | card | | `Input, Textarea, Select, Checkbox, Radio, Switch, Label` | controls (`Switch` has `role=switch`) | | `Field(id, label, control, opts...)` | label + control + `Help(s)` + `Error(s)`; `With(nodes...)` puts attributes on the group | | `CheckRow(control, label, id)` | checkbox/switch next to its label | | `Invalid()` | `aria-invalid="true"` (red ring) | | `Errors(errs, field)` | `Field` option: shows the message from `errs[field]` (a `trilha.FieldErrors`) if any | | `InvalidIf(errs, field)` | `Invalid()` only when there is an error for the field | | `SelectOptions([]Option{{Value, Label}}, selected)` | `<option>`s marking the selected one; `Value: ""` is a placeholder (disabled) and is selected when nothing matches | | `Checked(bool)` | conditional `checked` (round trip of checkbox/switch/radio) | | `ShowWhen(field, values...)` | `data-ui-show-when`: shows the element only with the value (or any non-empty value); hidden controls are disabled | | `Badge`, `Alert(title, ...)`, `AlertDescription(...)` | badge and alert (`role=alert`) | | `Toaster(...)`, `Toast(kind, text, fadeMs)` | toast stack; `kind` = `""`, `success`, `error`; `fadeMs > 0` disappears on its own | | `Flashes(c)` | the toaster with the messages of [`c.Flash`](/trilha/reference/ctx) — put it in the layout; `FlashInfo`, `FlashSuccess` and `FlashError` are the kinds | | `Table(...)`, `Num()`, `Depth(n)` | scrollable table; numeric cell; row indentation (tree) | | `Tabs(id, Tab{Label, Content}...)` | accessible tabs (arrows, Home/End); the first starts open | | `Dialog(id, title, ...)`, `DialogDescription(s)`, `DialogFooter(...)`, `DialogTrigger(id, ...)`, `DialogClose(...)` | native `<dialog>` with `showModal` | | `Confirm(title, description)` | attributes for a `<form>`: `ui.js` asks in a dialog before submitting, fragment forms included. The confirming button repeats the pressed button's label; the other says `Cancel`, or what `h.Data("ui-confirm-cancel", "…")` says. Without JavaScript the form submits straight away | | `Menu(id, ...)`, `MenuItem(...)`, `MenuLink(href, ...)`, `MenuTrigger(id, ...)` | menu with the native `popover` attribute | | `Pagination(Pages{Page, Total, Href, Prev, Next, Label})` | page navigation as links; the current page is a `<span>` with `aria-current`, the edges are absent instead of disabled, and a window of seven slots keeps the first and last page with `…` over each gap; one page renders nothing | | `Tooltip(text, ...)` | hint on what it wraps: `title` plus `data-ui-tooltip`, upgraded by `ui.js` into a bubble with `role=tooltip` and `aria-describedby` | | `Separator, Skeleton, Progress(value, max), Breadcrumb(Crumb{Label, Href}...), Avatar(initials, src), Collapsible(summary, ...)` | miscellaneous | | `ThemeToggle()` | button that switches light/dark (`localStorage["ui-theme"]`) | | `Swap(id)` | `data-trilha-target`: the `<a>` or `<form>` asks for element `#id` only and swaps it (fragments) | | `NoPush()` | `data-trilha-push="false"`: the swap leaves history alone | | `Icon(name, attrs...)`, `Icons()` | inline Lucide SVG; unknown name → panic (programming error) | ## ui.js Everything by attribute, no initialization: `[data-ui-tabs]`, `[data-ui-dialog-open=id]`, `[data-ui-dialog-close]`, `[data-ui-fade=ms]`, `[data-ui-show-when]`, `[data-ui-toast=text]` (`data-ui-toast-kind`), `[data-ui-theme-toggle]`, `[data-ui-tooltip=text]`, `[popover].ui-menu`. It also exposes `window.ui.toast(text, {kind, ms})`, `ui.fade(el)`, `ui.evalShowWhen(root)` and `ui.applyTheme("dark"|"light")`. Elements inserted later (HTMX, fetch) need `ui.evalShowWhen(el)`/`ui.fade(el)`/`ui.initTooltips(el)` if they use those attributes — `ui.hydrate(el)` does the three at once. ## Fragments `[data-trilha-target=id]` on an `<a>` or `<form>` (see `ui.Swap`) makes the kit request the same URL with the `Trilha-Fragment` header and swap element `#id` for the HTML that comes back. Details: the target gets `aria-busy` while it waits; **204 with `Trilha-Location`** becomes a real navigation; **422** focuses the first `[aria-invalid=true]`, otherwise focus (and the caret) return to the field in use; what came in is hydrated (`fade`, `show-when`) and fires `trilha:swap` (`detail.target`, `detail.status`). On 5xx, a network error or a fragment without the id, the kit gives up and navigates/submits normally. `ui.swap(id, html, status)` and `ui.hydrate(el)` do the swap by hand. See [Interactivity](/trilha/learn/interactivity). ## Navigation Client navigation is off until you ask for it, in two places: | Symbol | Role | |---|---| | `ui.Navigate(id) h.Node` | marks a region: a click on a same-origin link inside it replaces element `#id` with the same element from the next page. An empty `id` means the marked element itself | | `ui.NoNavigate() h.Node` | keeps one link out of it (a download, another app, a route that must reload) | | `ui.NavigateScript(c) h.Node` | `<script defer src=ui.nav.js>`; put it once, in the layout of the area that uses it | What the browser keeps doing: the address in the bar is the one a normal navigation would use, Back and Forward work (and restore the scroll position of the entry they return to), `Cmd`/`Ctrl`-click and middle click open a tab, `target`, `download` and links to another origin are untouched. What the kit adds: `aria-busy` on the region while it waits, focus moved to what came in, `ui.hydrate` and the `trilha:swap` event, and one request at a time — a second click aborts the first. On 5xx, a network error, a redirect or a page that does not contain the id, it gives up and navigates for real. The behavior is a separate file so an app that does not use it does not download it, and `ui.Head` does not load it. A link marked with `ui.Swap` stays with fragments: it asks for a piece of the page, not for the next page. ## Upload with progress A form that sends a file is a form: `method="post"`, `enctype="multipart/form-data"`, the CSRF field. Three symbols add the progress bar on top of it, and it is off until you ask: | Symbol | Role | |---|---| | `ui.UploadTo(id) h.Node` | on the `<form>`: send it with XHR and swap `#id` with what comes back | | `ui.UploadBar(attrs…) h.Node` | the `<progress>` the kit fills in; hidden until the send starts | | `ui.UploadScript(c) h.Node` | `<script defer src=ui.upload.js>`, once per page that uploads | The request carries `Trilha-Fragment: id`, so the handler answers the piece with the same `c.Fragment()` it already uses. While it uploads, the bar gets `value`/`max` from the browser's own progress event (and loses `value` — an indeterminate bar — when the total is not known), and a `trilha:upload` event bubbles with `detail: {loaded, total, form}`. On a 5xx, a network error or a piece without the id, the form submits for real: the user sees the page reload, not a button that did nothing. The attribute is `data-trilha-upload`, not `data-trilha-target`, so the fragment handler in `ui.js` does not submit the same form a second time. The body limit is the server's business — see [`AllowBody`](/trilha/reference/ctx). ## Theme `ui.theme.css` defines, in `:root` and `.dark`, exactly the shadcn/ui v4 variables: `--background/--foreground`, `--card/--card-foreground`, `--popover/…`, `--primary/…`, `--secondary/…`, `--muted/…`, `--accent/…`, `--destructive`, `--border`, `--input`, `--ring`, `--chart-1…5`, `--sidebar…`, `--radius`. `ui.css` derives `--radius-sm/md/lg/xl`. Dark mode is the `dark` class on `<html>` (the `ui.Head` script applies the saved or system preference before the first paint). ## CLI `trilha ui [--force] [--css-only|--js-only]` writes the five files in `public/`: `ui.theme.css` is only created (never overwritten); `ui.css`, `ui.js`, `ui.nav.js` and `ui.upload.js` are updated when they equal a previous version and, if you edited them, only with `--force`. --- # ai Source: /trilha/reference/ai OpenAI-compatible client, tools, agents, handoffs and composition. `import "github.com/emersonjoe/trilha/ai"` — no external dependencies. ## Client | Field / function | Role | |---|---| | `NewFromEnv() *Client` | reads `OPENAI_API_KEY`, `OPENAI_BASE_URL` (default `https://api.openai.com/v1`) and `TRILHA_AI_MODEL` (or `OPENAI_MODEL`; default `gpt-4o-mini`) | | `BaseURL, APIKey, Model string` | direct configuration | | `Headers map[string]string` | extra headers (OpenRouter, Azure...) | | `HTTPClient *http.Client` | HTTP client (default with a 2 min timeout) | | `Chat(ctx, Request) (*Response, error)` | one call; `Response.Text()` and `Response.ToolCalls()` | | `Stream(ctx, Request, func(Delta) error) error` | chunked response; `Delta.Content`, `Delta.ToolCalls`, `Delta.Usage` at the end | Non-2xx responses become `*ai.Error{Status, Code, Message}`. ## Request and messages `Request{Model, Messages, Tools, ToolChoice, Temperature, MaxTokens, ResponseFormat, Extra}`. `Extra map[string]any` is merged into the JSON sent, for provider-specific parameters. `ResponseFormat{Type: "json_schema", JSONSchema: ...}` asks for structured output. Constructors: `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 // validates the JSON; panics at startup if invalid func Typed[T any](fn func(ctx, in T) (string, error)) ToolFunc ``` `schema == nil` means "no arguments". Errors and panics from the function become text for the model (`error: ...`) and show up in `Step.Err`. ## Agent | Field | Role | |---|---| | `Name` | identifies the agent in `Step.Agent` and in handoffs (`transfer_to_<slug>`) | | `Instructions` | `system` message | | `Model` | overrides the client's model | | `Tools []*Tool` | tools | | `Handoffs []*Agent` | agents this one may transfer the conversation to | | `MaxTurns` | limit of model calls per `Run` (default 10; exceeded → `ErrMaxTurns`) | | `Temperature *float64`, `ResponseFormat` | passed on every request | ```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` serves as history for the next call (`system` messages from the history are ignored; the current agent's apply). `Event.Type`: `text` (`Text`), `tool_call` and `tool_result` (`Step`), `handoff` (`Step.HandoffTo`, `Agent` = new agent), `done` (`Result`), `error` (`Err`). Tools of the same round run in parallel; the order of results in the history is the order the model asked for them. A handoff swaps the `system` message, keeps the history and continues the loop with the target agent. ## Composition ```go func (a *Agent) AsTool(cli *Client, description string) *Tool // {"input": "..."} → text func Parallel(ctx, cli, input string, agents ...*Agent) ([]*Result, error) func Chain(ctx, cli, input string, agents ...*Agent) (*Result, error) ``` `Parallel` returns in the agents' order and propagates the first error; `Chain` passes one's `Output` as the next one's `input`. --- # mcp Source: /trilha/reference/mcp Model Context Protocol client and server (stdio and Streamable HTTP). `import "github.com/emersonjoe/trilha/ai/mcp"` — JSON-RPC 2.0, revision `2025-03-26`, no external dependencies. Covers the *tools* capability (list and call). ## Client ```go func Dial(ctx, dial Dialer) (*Client, error) // opens the transport and runs initialize func Stdio(name string, args ...string) Dialer // child process, JSON per line func HTTP(url string, headers map[string]string) Dialer // Streamable HTTP (POST per message) ``` | Method | Role | |---|---| | `ListTools(ctx) ([]ToolInfo, error)` | follows pagination (`nextCursor`) | | `CallTool(ctx, name, args) (CallResult, error)` | `CallResult.Text()` joins the text items | | `Tools(ctx) ([]*ai.Tool, error)` | tools ready for an `ai.Agent`; `isError` becomes an error | | `Server.Name/Version/ProtocolVersion` | filled by `initialize` | | `Close()` | closes the transport and ends the child process | The HTTP client keeps the `Mcp-Session-Id` received on `initialize` and sends it on the following messages; it accepts JSON or `text/event-stream` responses. ## Server ```go func NewServer(name, version string, tools ...*ai.Tool) *Server func (s *Server) ServeHTTP(c *trilha.Ctx) error // in app/.../route.go: POST func (s *Server) Handler() http.Handler // outside Trilha func (s *Server) ServeStdio(ctx, r io.Reader, w io.Writer) error ``` Methods served: `initialize`, `ping`, `tools/list`, `tools/call`; notifications are accepted without a response (`202`). Over HTTP, `initialize` emits `Mcp-Session-Id`; messages without a valid session get `404`; sessions expire after `SessionTTL` (1 h) without use. Only `POST` is accepted (`405` with `Allow: POST` for the rest). Body limited to 4 MiB. Tool errors and panics become a result with `isError: true`, as the protocol requires; an unknown tool is JSON-RPC error `-32602`. ## Your own transport `Transport` is an interface (`Send`, `Recv`, `Close`). `Pipe(r, w, closer)` builds the line transport over any reader/writer pair, which the tests use with `io.Pipe`. --- # CLI Source: /trilha/reference/cli The trilha commands and their options. ```text trilha new <dir> [--module path] [--lang en|pt] [--agents] [--trilha-dir ../trilha] [--no-tidy] trilha gen [--check] [--package name] trilha generate page|route|test <url> | component <Name> [--methods GET,POST] [--bind Type] [--form Type] [--layout file] [--force] [--dir path] [--lang en|pt] trilha dev [--addr :3000] trilha build [-o bin/<name>] trilha export [-o out] [--base /prefix] trilha openapi [-o file] [--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 ``` | Command | What it does | |---|---| | `new` | creates a project with `go.mod`, layout, home page, 404, one API route, `public/style.css` and `.gitignore`; runs `go mod tidy` and `gen` | | `gen` | scans `app/` and writes `trilha_gen.go`; fails with one line per violated convention | | `generate` | writes one skeleton — a page, an API route or a component — in the folder the convention asks for | | `dev` | `gen` + `go build` + runs the app on an internal port + proxy on `--addr` + reload over SSE + route inspector on `/_trilha/routes` | | `build` | `gen` + `go build -trimpath -ldflags="-s -w"` with `CGO_ENABLED=0` | | `export` | `gen` + `go build` + runs with `TRILHA_EXPORT` to produce static HTML | | `openapi` | writes the OpenAPI 3.1 document of the API routes (`-o -` to stdout) | | `routes` | prints `METHODS PATTERN SOURCE` for each route | | `check` | the single gate: `gen`, `gofmt`, `vet`, `test`, `audit` and `openapi`, in that order, stopping at the first failure | | `ctx` | the map of the project — routes, API, types, setup — in one read, as Markdown or JSON | | `audit` | security checklist before publishing (see [Security](/trilha/reference/security)) | | `agents` | writes `AGENTS.md` and `CLAUDE.md` so a coding agent finds the conventions | Commands run in the folder containing `app/`. The project's import path comes from the nearest `go.mod`, plus the subfolder, so an app can live inside a larger module. ## Language CLI messages follow `TRILHA_LANG`, then `LC_ALL`, `LC_MESSAGES` and `LANG`: a value starting with `pt` (any case) selects Portuguese; anything else, including an unset variable, selects English. Messages from the runtime, the scanner and the generator (the ones that end up in your code and logs) are always in English. `trilha new --lang en|pt` chooses the language of the generated texts (home page, 404, `<html lang>`); the default is the CLI's language. ## trilha dev Besides the proxy and the reload, the supervisor serves the route inspector on `/_trilha/routes`: the table of routes in precedence order with layouts and middlewares per route, and a box that answers which pattern would serve a given path. The page belongs to the supervisor, not to the app, so it does not exist in the binary `trilha build` produces — see [Development and production](/trilha/learn/dev-and-production#the-route-inspector). ## trilha generate The convention is what costs to remember: that `/blog/{slug}` lives in `app/blog/slug_/`, that a catch-all folder ends in `__`, that a group ends in `-`. `generate` takes the URL and does the translation: ```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 ``` The page and the route come out compiling, with `c.Param` already reading each parameter, and `trilha_gen.go` is regenerated at the end — the URL answers before you open the editor. A component is a function returning `h.Node`, so it composes like any other; `--dir` puts it somewhere else (`internal/icons`, for instance). The package name is the one already declared in the folder, when there is one; otherwise it comes from the folder name (`slug_` → `slug`, `relatorio.csv` → `relatoriocsv`, `type` → `type_`). An existing file is not overwritten without `--force`, and `--force` does not cover the one refusal that is a convention: a folder answers either a page or a route, never both. ### The contract, not only the folder Without flags the skeleton is generic, and what is left to write — the struct, the `Bind`, the validation, the answer, the test — is exactly where a signature gets typed wrong. The flags write that part: ```bash trilha generate route /api/posts/{id}/comments --methods GET,POST --bind Comment trilha generate page /contact --form Contact --layout app/layout.go trilha generate test /api/posts ``` - `--methods` writes one handler per method, in the signature the scanner reads, with `c.Param("id")` already there for each parameter of the path. - `--bind Type` makes the methods that carry a body do `c.BindJSON(&in)`: returning that error is the 422 with the fields, so there is nothing else to handle. A type the project already declares is imported from where it is; one it does not have is born in the route's package with example `json` and `validate` tags. A name declared in two packages is refused, and the message says to write `posts.Comment`. - `--form Type` on a page writes the whole round trip: `trilha.CSRFInput`, one `ui.Field` per field with the message beside it, 422 with `trilha.FieldErrors` when the `Bind` refuses and `POST → redirect → GET` when it accepts. - `--layout <file>` writes the `layout.go` that is missing above the page. A path that does not wrap the page is refused: the scanner would never apply it, and finding that out costs a round trip. - `generate test <url>` writes the test next to the route, in its own package, with one case per method the scanner finds — and a body built from the tags when it can read the type the handler binds. Right after generating, `trilha check` is green with nobody editing anything. `--lang en|pt` chooses the language of the comments in the skeleton; identifiers, field names and error messages stay in English. ## trilha ui Writes or updates the UI kit in `public/`: `ui.theme.css` (only created; it is your theme), `ui.css` and `ui.js` (updated; if edited locally, only with `--force`). `--css-only` and `--js-only` limit what is touched. `trilha new` runs the same step. See [UI kit](/trilha/learn/ui-kit). ## trilha agents Writes two files at the root of the project, and only when asked: support for coding agents is opt-in, so `trilha new` on its own leaves neither behind. `trilha new --agents` adds them at creation time. | File | Who owns it | |---|---| | `AGENTS.md` | the framework: the conventions, the commands, and what not to do | | `CLAUDE.md` | you: three lines pointing at `AGENTS.md`, plus whatever this repository needs | `AGENTS.md` carries a stamp with the hash of its own body, the same rule the ui kit uses. An untouched copy from an older version is refreshed in silence on the next run; one you edited is only overwritten with `--force`, and without it the command stops and says so. `CLAUDE.md` is never overwritten. `--lang en|pt` picks the language of both files and defaults to the CLI's. Run it again after upgrading the CLI: `AGENTS.md` names the commands of the version that wrote it, so a copy from an older release keeps sending the agent to commands that were replaced. The whole sequence for a project coming from an older version is in [Migration](/trilha/cookbook/migration#turning-on-the-agent-files-in-a-project-that-already-exists). ## trilha openapi Reads `app/`, deduces the document from the handlers and writes `openapi.json`. `-o -` writes to stdout; `--title`, `--version` and `--server` fill what the code cannot know (they default to the module name, `0.0.0` and no server). `--check` compares with the file on disk and exits `1` when they differ — the same line `gen --check` is, for the same reason: ```yaml - run: trilha openapi --check ``` What is deduced and the `openapi:` directives are in [APIs](/trilha/learn/api#the-openapi-document). ## trilha check Six gates in one command, in the order that fails cheapest first: `gen`, `gofmt`, `vet`, `test`, `audit` (without the vulnerability scan, which needs the network) and `openapi` (only if the project keeps the document). It stops at the first failure — what comes after a broken build says nothing about the project — and the steps that never ran say so: ```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) ``` Every problem carries the file, the line and the sentence that resolves it. `--fix` rewrites `trilha_gen.go` and the formatting before judging them, and the step then reports `fixed`. `--json` writes the report a tool reads, with the same fields: ```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" } ] } ``` Exit `1` when anything failed, so in CI it is the single line: ```yaml - run: trilha check ``` ## trilha ctx The map of the project in one read: the module, whether `trilha_gen.go` is up to date, every route with its file, methods, parameters, layouts and middlewares, each API operation with its query, body and responses, the types those operations exchange, and what `app/setup.go` provides: ```text # example.com/store - 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 ... ``` The default is compact Markdown for reading. `--routes` and `--types` print one section alone, `--all` elides nothing (the per-method middlewares, every error response, the `Problem` type), and `--json` writes the same model as a document, sorted and free of clocks and absolute paths, so two runs of the same tree produce the same bytes. The API section and the types come from the same inference behind `trilha openapi`, so the map and the document can never disagree. Like `openapi.json`, the output itself is a machine document and is not translated. ## trilha gen --check Generates in memory, compares with the committed `trilha_gen.go` and exits `1` with the differing lines when they diverge — one line in the CI, and a folder added to `app/` without running `trilha gen` stops being a 404 nobody can explain: ```yaml - run: trilha gen --check ``` `trilha check` runs this same comparison as its first gate, which is why a project that uses it needs no separate `gen --check` line. `trilha audit` runs the comparison as a warning, and also compares the CLI's version with the library's in `go.mod`: a newer CLI writes code the library may not have yet, and the error then shows up inside generated code — the worst place to look for it. ## Generated file `trilha_gen.go` is deterministic (same tree, same bytes), carries the header `// Code generated by trilha. DO NOT EDIT.` plus a `//go:generate trilha gen` directive (so `go generate ./...` works without knowing the tool's name) and must be committed: `go build ./...` works without the CLI installed. It defines `newApp() *trilha.App` and `main()`; if another file in the package already has `func main()`, the generator omits its own (see [App](/trilha/reference/app)). ### An app inside a binary that already exists The generated file takes the package the folder declares, so a Trilha app can be a normal, importable package inside a `net/http` server you already run: ```go // internal/crm/crm.go — package crm, written by hand // internal/crm/app/… — the routes // internal/crm/trilha_gen.go — package crm, func NewApp() *trilha.App mux.Handle("/", crm.NewApp().Handler()) ``` Precedence, most explicit first: `--package <name>`; the package the hand-written `.go` files of the folder declare; the package an existing `trilha_gen.go` declares; `main`. The third step is what makes the flag a one-off — the generated file remembers the choice, so `trilha gen --check` in the CI needs no flag of its own. Outside `package main` the constructor is exported (`NewApp`, since the caller lives in another package) and no `func main()` is written. `trilha dev` and `trilha build` refuse such an app and say what runs it: there is no binary here, the host has one. ## Exit codes `0` success; `1` generation, compilation or execution error; `2` incorrect usage. --- # Performance and comparison Source: /trilha/reference/performance How much Trilha costs over the standard library, how to measure it yourself, and how it compares with other approaches. ## Methodology The only number worth publishing is the **cost of the framework over the standard library**, which is the real alternative in Go. The benchmarks live in `bench/` (a separate module, so Trilha stays dependency-free) and measure, in process (`httptest`, no network), the same work done two ways: with Trilha and with plain `net/http` + `html/template`. ```bash git clone https://github.com/emersonjoe/trilha && cd trilha make bench # runs; make bench-results rewrites bench/RESULTS.md ``` Scenarios: page with layout and 20 items (`h` × `html/template`), JSON response, static file (`Public` × `http.FileServer`), 200 routes with a parameter (`ServeMux` on both sides) and a chain of 5 middlewares. ## Reference results Apple M2, Go 1.25, 2026-09-05 (median of 3 runs; `bench/RESULTS.md` has the full output). Values per request. | Scenario | Stdlib | Trilha | Difference | |---|---|---|---| | Page (20 items, layout) | 29.4 µs · 270 allocs | 19.4 µs · 482 allocs | `h` is ~34 % faster than `html/template` here, with more allocations | | JSON (20 items) | 4.2 µs | 7.6 µs | +3.4 µs | | Static (1.4 KB) | 1.4 µs | 4.3 µs | +2.9 µs | | 200 routes + parameter | 0.72 µs | 4.0 µs | +3.3 µs | | 5 middlewares | 0.64 µs | 4.1 µs | +3.4 µs | Honest reading: Trilha has a **fixed cost of ~3 µs and ~40 allocations per request**, regardless of the route. It pays for: request id (random), CSP nonce, security headers, `Ctx` with a value map, body limit, timing and **structured logging** of every request (`slog`, which formats the line even when discarded). In a real server a database query costs 100 µs to a few ms, and the network more; the difference disappears. If that ever matters to you, the path is reducing allocations in `Ctx` and making logging optional per route — and the benchmark is there to prove the gain. ### Observability | Scenario | Without metrics | With metrics | Difference | |---|---|---|---| | Trivial route (`c.Text`) | 4.1 µs · 50 allocs | 4.1 µs · 50 allocs | within noise; **zero allocations** | | `/_trilha/health/live` probe | — | 0.9 µs · 18 allocs | bypasses the router and the middleware chain | Instrumentation only exists when `Observability.Metrics` is configured; off, it is a pointer comparison. On, the series key is built in a stack buffer and looked up as `map[string(bytes)]`, a form the compiler resolves without allocating — which is why the allocation count does not change. The **edit → see** cycle of `trilha dev` is ~1.2 s in the blog example (Go recompilation) and ~30 ms for changes only in `public/` (`make reload` measures on your machine). ## Comparison of approach No third-party numbers: versions change, configurations differ and each project optimizes for different things. What can be compared safely is the **approach**. Always check each project's documentation; names cited are trademarks of their respective owners and there is no affiliation. | | Trilha | plain `net/http` | Go routers (chi, echo, gin, fiber) | templ + htmx | Next.js | |---|---|---|---|---|---| | Routes | by folders in `app/` (`page.go`, `route.go`) | registered by hand | registered by hand | registered by hand (with the router you choose) | by folders in `app/` | | Nested layouts | `layout.go` per folder | manual | manual | components | `layout.tsx` | | HTML | typed `h` DSL (escaped by default) or `html/template` | `html/template` | `html/template` or libs | `templ` (compiled) | JSX/React | | Client interactivity | HTML + `ui.js` (200 lines) or htmx; no hydration | your choice | your choice | htmx | React (hydration, RSC) | | Runtime dependencies | none | none | the router (+ deps) | `templ` (+ generator) | Node, React, Next | | Dev | `trilha dev`: ~1 s reload, compile error on the page | manual `go run` | `air`/manual | `templ generate --watch` + reload | `next dev` (HMR) | | Production | one static binary with `public/` embedded | binary | binary | binary | Node or edge; build | | Static export | `trilha export` | manual | manual | manual | `output: 'export'` | | Default security | CSP with nonce, HSTS, CSRF, rate limit, signed cookies, timeouts | nothing (you configure) | varies | nothing (you configure) | basic headers; CSRF in Server Actions | | AI | `ai` (OpenAI-compatible), `ai/mcp` | — | — | — | Vercel AI SDK (package) | When **not** to use Trilha: apps that need a highly interactive client UI (editors, real-time dashboards with complex state) are better served by React/Next or by an SPA; and projects that already have a Go router and mature templates gain little by switching. Trilha shines in server-rendered business apps, content sites and APIs with a dashboard, where a dependency-free binary and strong conventions weigh more than fine-grained interactivity. ## Cost per feature for an agent The numbers above are what the framework costs per request. There is a second cost, paid by whoever writes the app with an AI tool: the tokens an agent spends discovering what the project already has, getting a signature wrong, running five checks one at a time. That is what `bench/agent` measures. `make bench-agent` copies `examples/blog` or `examples/sso` into a module of its own, runs a coding agent (`claude -p`, with no MCP servers, plugins or user memory: only what is inside the project counts) on four fixed tasks, and decides pass or fail with a hidden test: | Scenario | Task | |---|---| | `comments` | `POST`/`GET /api/posts/{id}/comments` with `Bind`, validation, 404 | | `contact-form` | a `/contato` page inside the root layout with a `ui` form | | `cognito` | switch the login provider of the SSO example from Keycloak to Cognito | | `pagination` | five posts per page at `/blog`, with `?page=N` and prev/next | Each scenario runs three times; `bench/agent/RESULTS.md` shows the median of tokens in (fresh and read from cache), tokens out, turns, denied tool calls, time and cost, and how many runs passed. The comparison is always Trilha before against Trilha after — same task, same agent, same model — never against another framework. `make bench-agent-dry` builds the fixtures and proves the hidden tests fail without an agent, spending nothing; the CI never runs the agent. --- # Cookbook Source: /trilha/cookbook The parts every app needs and no framework decides for you — database, session, upload, pagination, e-mail, scheduled task, Docker — with code that compiles. "Learn" teaches the framework and "Reference" describes each symbol. This section answers a third kind of question, the one that shows up on the second day: *how do I do the thing every app does?* Open a database, keep someone logged in, receive a file, paginate a list, send an e-mail, run a task every hour, put the whole thing in a container. None of that is the framework's decision. Trilha has no ORM, no session store and no mailer — what it has is a place for yours, and this is where the placing is written down. | Recipe | What it answers | |---|---| | [Database](/trilha/cookbook/database) | pool, queries, transaction, migrations, sqlc | | [Sessions](/trilha/cookbook/sessions) | login, signed cookie, current user, flash | | [Uploads](/trilha/cookbook/uploads) | receive a file, validate it, store it, hand it back | | [Pagination](/trilha/cookbook/pagination) | page and cursor, and the footer that goes with them | | [E-mail](/trilha/cookbook/email) | SMTP in production, the log in dev, a body from a template | | [Scheduled tasks](/trilha/cookbook/scheduled-tasks) | a ticker that starts with the app and stops with it | | [Docker](/trilha/cookbook/docker) | a small image, the variables, the health probe | | [Production checklist](/trilha/cookbook/production-checklist) | what to check before publishing, in order | | [Migration](/trilha/cookbook/migration) | plain `net/http` to Trilha, and between minor versions | ## Where the code comes from Every Go block on these pages is copied from a file in [`examples/cookbook`](https://github.com/emersonjoe/trilha/tree/main/examples/cookbook), which is part of the repository's module: `go vet ./...` compiles it on every run, and a site test checks that each block still appears, character for character, in the file it came from. A recipe that stops compiling breaks the build before it can mislead anyone. That has a price worth knowing about: the package uses the standard library only, like the rest of the repository. So there is no database driver, no password hash and no metrics client in it. Where one is needed, the page says which line to add and why it is not here. :::note The recipes assume the conventions from [Pages and routes](/trilha/learn/pages-and-routes) and the `app/setup.go` from [App](/trilha/reference/app). If a snippet mentions `Setup`, it belongs in that file; if it mentions `Config`, it runs before the app exists. ::: --- # Database Source: /trilha/cookbook/database One pool for the process, queries that carry the request's context, a transaction that rolls back on its own, and migrations applied in order. Trilha does not open your database. What it gives you is the two moments that matter: `Setup`, which runs once before the server starts, and the request's context, which is what makes a query stop when the visitor gives up. ## The pool `database/sql` is already a pool. One per process — a pool per package is four connection ceilings nobody added up, and a pool per request is a connection storm on the first busy minute. ```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 } ``` The `import` that makes `"pgx"` a real name is the one line this file cannot have, because the repository has no external dependency: ```text import _ "github.com/jackc/pgx/v5/stdlib" // driver "pgx" import _ "modernc.org/sqlite" // driver "sqlite", no cgo ``` For SQLite there is one more thing to say, and it is not optional: `_pragma=journal_mode(WAL)` in the DSN, plus `db.SetMaxOpenConns(1)` for writes. Without WAL, the second concurrent write gets `database is locked`, and it will happen in production and not in your tests. ## Where it is opened ```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 } ``` Three things in six lines, and the last two are the ones people forget. `a.Check` makes the pool part of `/_trilha/health/ready`, so an instance that lost the database stops receiving traffic instead of answering 500 to everyone. `a.OnShutdown` closes it after the last request, not during it. ## Reading ```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` is the most common bug in this file. It is not a failure of the server: it is the page not existing. Returning `trilha.ErrNotFound` turns it into the 404 the visitor deserves — and, for an `/api` route, into a `problem+json` body with the right status. A list is the same, with the rows closed by a `defer` and `rows.Err()` checked at the end, because a broken connection halfway through looks exactly like the end of the list: ```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() } ``` :::warning The context comes from `c.Context()`, always. A query started with `context.Background()` inside a handler keeps running after the visitor closes the tab, holding a connection for an answer nobody will read. ::: ## Writing, and undoing ```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() } ``` The unconditional `defer tx.Rollback()` is the point: rolling back a transaction that already committed does nothing, so the deferred call is free in the happy path and is the only thing that closes the transaction when the code between panics. ## Migrations A migration tool is a fine choice. It is also a dependency, a binary in the image and a step in the deploy — and the whole thing is thirty lines with `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 } ``` The file and its receipt land in the same transaction: either both or neither. Call it from `Setup` before the server listens, or from a separate command if your deploy applies migrations before rolling the new version — which is the better shape once there is more than one instance. ## sqlc Everything above writes the `Scan` by hand. [sqlc](https://sqlc.dev) generates it from the SQL you already wrote: a `.sql` file in, a typed method out, and a compile error when a column changes name. ```yaml version: "2" sql: - engine: postgresql queries: internal/db/query.sql schema: internal/db/migrations gen: go: package: db out: internal/db ``` It fits the framework without any adapter, because what comes out is ordinary Go: the generated `*db.Queries` goes in `Setup` exactly where `DB` goes above. The trade is the generator in the loop — one more command to run when the SQL changes, and one more thing to explain to whoever joins. :::note sqlc runs at build time, so it is not a runtime dependency of your app. That distinction is what makes it a different decision from adding an ORM. ::: --- # Sessions Source: /trilha/cookbook/sessions Login with a signed cookie, the current user in a middleware, a flash message that survives one redirect — and nothing stored on the server. A session is two decisions: what proves who you are, and where that proof is kept. Trilha answers the first — a cookie signed with the app's secret, which the browser cannot forge — and leaves the second to you. This recipe keeps nothing on the server: the cookie carries the user id, and every request reads the user from the database. That costs one indexed query per request and buys something worth more: disabling an account takes effect now, not when the cookie expires. ## Logging in ```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` writes the cookie with `HttpOnly`, `SameSite=Lax` and `Secure` outside dev, and signs the value with the app's `Secret`. The value is not encrypted and does not need to be: it is the visitor's own id. ```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" ``` The password check is the one thing the framework will not do for you, and neither will the standard library: ```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 } ``` In your app, that variable points at `bcrypt.CompareHashAndPassword` or `argon2.IDKey`. Here it refuses everyone, so that forgetting to wire it fails closed. ```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 } ``` Two details earn their lines. The single error means the login page cannot be used to find out which e-mails have accounts. The `dummyHash` means it cannot be used by timing either: ```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" ``` And the redirect after login goes through a check, because `?next=` is the classic open redirect — a login page that sends people to another site after they type their password: ```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 } ``` ## The current user The middleware runs for every route in the folder it lives in and below, so `app/middleware.go` covers the whole app: ```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() } ``` It refuses nobody on purpose. A page that requires a login says so itself, and a page that only greets by name works either way: ```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 } ``` The zero `User` means "nobody", so a page can ask without checking twice. Reading the user is one query, and it is the query that gives the session its teeth: ```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 } ``` :::note Want the session in a store instead? Change `UserByID` to read your store and keep everything else. The cookie still carries an opaque id; what changes is where the id is looked up. ::: ## Logging out ```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("/") } ``` There is nothing else to forget, which is the advantage of a stateless session — and its limit: a stolen cookie stays valid until it expires. If you need to revoke one, you need the store. ## Flash The message that has to survive a redirect and then disappear: ```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 } ``` Signed, so nobody can put text of their own on your page by editing a cookie. Read once, so a reload does not show it again. :::tip Testing this needs no HTTP client: `trilha.WithSigned("session", "42")` writes a valid session in a test request, and `res.Cookie("session")` is how you prove a logout cleared it. See [Testing](/trilha/learn/testing). ::: --- # Uploads Source: /trilha/cookbook/uploads Receive a file with a ceiling, check what it really is, store it outside the served tree, and hand it back without letting it run. An upload is the shortest path from a form to a security incident: a body with no limit, a type taken from the file name, a path that walks out of the directory, and an HTML file served back from your own origin. `c.File` closes the first three; the fourth is a decision about how you serve it. ## Receiving ```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` does four things before your code sees the file: | Check | What it prevents | |---|---| | `MaxSize` | one file bigger than the ceiling, refused as a field error | | `Accept` | a type that is not on the list, sniffed from the content, not the name | | the name | `../../etc/passwd` and the like: `Save` writes a name it made up | | `Optional` | telling "no file" apart from "a broken file" | The sniffing matters more than it looks. A browser sends whatever `Content-Type` it likes and a script sends whatever it wants; the only thing that says what a file is, is the file. ```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` is the other half of the limit. `MaxSize` refuses a file that is too big after reading it; the body limit stops the request from getting that far — a multipart upload with no end is a slow way to fill a disk. The name that goes in the database is the one `Save` returned, never the one the browser sent: ```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 } ``` ## Handing it back Serving user content from the same origin as your app is how a stored XSS gets a session cookie. The mount plus three headers is the whole answer: ```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` answers only for what is under the directory, so `..` in a URL reaches nothing. The headers say the rest: do not guess the type, do not render it, download it. :::warning The strong version of this is a different host — `uploads.example.com`, or a bucket with its own domain. Same-origin content is only ever as safe as the headers you remembered; another origin is safe because the browser will not let it touch your site. ::: ## Where the files live `UploadDir` is a directory outside `public/`, and outside the binary's tree: ```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" ``` On one machine that is a volume. On more than one it has to be shared storage or object storage, because the instance that received the file is not the one that will be asked for it. That is the moment `Save` moves to an S3 client — the handler above does not change, only what `Save` writes to. :::tip The progress bar and the drag-and-drop area are already in the kit: `ui.UploadBar`, `ui.UploadTo` and `ui.UploadScript`, in [the ui reference](/trilha/reference/ui). ::: --- # Pagination Source: /trilha/cookbook/pagination Page by offset while the list is short, by cursor when it is not, one extra row instead of a COUNT, and links a crawler can follow. A list that grows gets paginated twice: once with `LIMIT`/`OFFSET`, because it is obvious, and once with a cursor, when someone notices page 900 takes four seconds. Both are here, and the first is fine for most lists. ## The window ```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} } ``` The ceiling is not paranoia. `OFFSET 18000` makes the database read and discard eighteen thousand rows, and a crawler will ask for page 900 of everything you publish. ```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 } ``` ## One extra row ```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 } ``` Asking for `Size+1` and showing `Size` answers "is there a next page?" without a second query counting the whole table. `COUNT(*)` on a large table is the query that shows up in the slow log two months later — and the total is almost never what the footer needs. The order has two columns for a reason: `published_at` alone is not unique, and a tie split across a page boundary shows the same row twice or skips it. ## The 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 } ``` The `WHERE (published_at, id) < ($1, $2)` is what makes it cheap: with the index on those two columns, the database jumps to the position instead of counting to it, so page one thousand costs what page one costs. It also fixes the bug offset pagination has by design — a row inserted while someone reads shifts every page after it. ```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 because it travels in a URL, not because it hides anything: whoever edits it sees another page of the same public list. ## The footer ```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, not buttons. A page number belongs in the address so it can be shared, reloaded and indexed; `rel="prev"`/`rel="next"` is what a crawler reads to understand the sequence. :::tip `c.Fragment()` turns this into a list that grows in place without a full reload: the handler answers only the `<ul>` when the request is a fragment. See [Interactivity](/trilha/learn/interactivity). ::: :::note Which one to use: offset while the list is browsed by people who jump to a page, cursor for anything a machine walks through — an API, an export, an infinite scroll. The API answer is the cursor, always: page numbers over a changing list return duplicates. ::: --- # E-mail Source: /trilha/cookbook/email One interface the handlers call, SMTP behind it in production, the log in dev, a body from a template, and headers that refuse to be injected. Sending mail is three problems wearing one coat: talking to a server, assembling a message that is valid, and not sending anything from a test. Only the first is about SMTP. ## The seam ```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 } ``` An interface with one method, defined where it is used. The handlers never learn which implementation they got, which is the entire point: a test that signs a user up must not send mail to a real address. ```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` files the mailer under `Mailer`, the interface, and not under the struct that happens to be behind it today — that is what the type argument is for. A handler that asks for `Mailer` gets the log in dev and SMTP in production, and never learns the difference. Production without an address configured refuses to start. That is deliberate: a sign-up that silently sends nothing is discovered by a customer, and a process that will not boot is discovered by the deploy. ## Sending ```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` takes no context, and a mail server that stops answering would otherwise hold the request until the write timeout. The `select` gives the deadline back to the handler; the goroutine finishes on its own. :::note Port 587 with `PlainAuth` means STARTTLS, and `net/smtp` refuses plain authentication on a connection that is not encrypted — that refusal is a feature. Port 465 is implicit TLS, which `net/smtp` does not do on its own: dial with `tls.Dial` and use `smtp.NewClient`. ::: ## The message ```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 } ``` The loop at the top is the only security check in this file and the one that is usually missing. A newline inside a header is how a "name" field from a form becomes a second `Bcc:` — your server, someone else's mailing list. Refusing is right; escaping is a guess. The body comes from `text/template`, not `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()) } ``` HTML escaping in a plain-text mail turns an apostrophe into `'` in somebody's inbox. If you send a multipart HTML mail, then `html/template` is right for that part — and the plain one still goes along, because a lot of clients show it. ## In 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 } ``` The whole message in the log, including the link, which is what you actually need when you are testing a password reset for the fifth time. :::tip Two other implementations pay for themselves: one that collects messages in a slice, for tests to assert on, and one that writes `.eml` files to a directory so you can open them in a mail client. ::: --- # Scheduled tasks Source: /trilha/cookbook/scheduled-tasks A ticker that starts with the app and stops with it, one tick at a time, a panic that does not take the process down — and the line where cron becomes the right answer. Some work has no request behind it: expiring sessions, sending a digest, retrying what failed. While there is one instance, a goroutine with a ticker is the whole answer and it lives inside the app, with the same pool and the same logger. ## The shape ```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) } } } ``` A `time.Ticker` drops a tick when nobody is receiving, and that is the behaviour you want: a job that takes longer than its interval must not accumulate copies of itself. The loop calls the job and only then waits again. ```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)) } ``` The `recover` is not optimism about your code. A panic in a goroutine takes the whole process down — the HTTP server included — so an unhandled bug in the nightly digest would stop the site. Logged and skipped, it costs one run. ## Starting and stopping ```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 } ``` The context cancels on shutdown and the `WaitGroup` makes the process wait for the tick in flight. Without it, a deploy kills a job halfway: the row was written, the e-mail was not, and it is the hardest kind of bug to reproduce. The job itself is an ordinary function taking a context, so a test calls it directly, with no timer involved: ```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 } ``` ## When one instance stops being true The moment there are two instances, both tick. Every job runs twice, and "send the digest" becomes "send the digest twice". | Situation | What to do | |---|---| | one instance | this recipe | | more than one, idempotent job | keep it; running twice changes nothing | | more than one, job that must run once | a lock in the database | | work that must survive a restart | a queue, not a ticker | The lock is smaller than it sounds — one row per job name, a conditional update that only succeeds for the instance that gets there first: ```sql UPDATE job_locks SET locked_until = now() + interval '5 minutes', owner = $1 WHERE name = $2 AND locked_until < now(); ``` If the update touched no rows, another instance is running it, and this one skips the tick. :::note And there is nothing wrong with `cron` calling a route with a token, or a systemd timer running your binary with a subcommand. The advantage is separation: a task that hangs does not hold a slot in the server. The cost is one more thing to deploy. ::: --- # Docker Source: /trilha/cookbook/docker A static binary in a distroless image, the assets already inside it, the variables it needs, and a health probe the orchestrator can use. A Trilha app is one binary with the pages compiled in and, if you used `//go:embed`, the static files too. That makes the image small enough that the interesting part is what you leave out. ## The Dockerfile ```dockerfile FROM golang:1.22 AS build WORKDIR /src # Dependencies first: this layer is cached until go.mod changes. COPY go.mod go.sum ./ RUN go mod download COPY . . # The generated file is committed, but generating it again in the build is # how you find out someone forgot to run 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 # The port is documentation; the platform decides what it publishes. EXPOSE 3000 USER nonroot ENTRYPOINT ["/app"] ``` Two lines carry the weight. `CGO_ENABLED=0` makes a static binary, which is what lets the second stage be `distroless/static` — no shell, no package manager, nothing to exploit that is not your code. `nonroot` means a bug in your app is a bug running as uid 65532. :::warning `CGO_ENABLED=0` and the SQLite drivers that need cgo are mutually exclusive. Either use a pure-Go driver (`modernc.org/sqlite`) or build on `debian:bookworm-slim` and accept the bigger image. ::: ## The address `trilha.ConfigFromEnv` already reads `PORT` and `ADDR`, so the platform that hands over a port is obeyed with no code — the default is `:3000`, and `:3000` means every interface, which is what a container needs. The most common broken image is the one that was told to bind `127.0.0.1`: inside the container that is the container itself, and nothing outside reaches it. ## The variables | Variable | What it is | |---|---| | `TRILHA_ENV` | `prod` — turns off the dev reload, the error page with source, the verbose log | | `TRILHA_SECRET` | the key for cookies and CSRF; at least 32 bytes, from the platform's secret store | | `PORT` or `ADDR` | where to listen; `:3000` by default | | `DATABASE_URL` | your pool's DSN | | `TRILHA_BASE_PATH` | only when the app is not at the root of the domain | A secret baked into the image is a secret in the registry, and in every layer cache that ever pulled it. Rotating one is `TRILHA_SECRET_PREVIOUS` with the old value for a deploy or two, so sessions signed with the old key keep working while they expire. ## Compose ```yaml services: app: build: . environment: TRILHA_ENV: prod DATABASE_URL: postgres://app:app@db:5432/app?sslmode=disable env_file: [.env] # TRILHA_SECRET lives here, not in this file 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: ``` A distroless image has no `curl` and no shell, so the health check cannot be a shell command against a URL. Two ways out: a `-health` flag in your own binary that requests `/_trilha/health/ready` and exits with the status, or the orchestrator's own probe — which is what Kubernetes does, and it does not need anything inside the image: ```yaml livenessProbe: httpGet: { path: /_trilha/health/live, port: 3000 } readinessProbe: httpGet: { path: /_trilha/health/ready, port: 3000 } periodSeconds: 5 ``` `live` says the process is up; `ready` says it can serve, and it is the one that goes red when the database is gone. Wiring them the other way round is how a container that lost its database gets restarted forever instead of being taken out of the pool. :::note There is a smaller answer than a container. `trilha export` writes a static site when the app has no dynamic route, and `trilha build` writes the binary if all you need is to copy a file to a machine and run it under systemd. Not everything needs an orchestrator. ::: --- # Production checklist Source: /trilha/cookbook/production-checklist What to check before publishing, in order: what trilha audit finds for you, what it cannot see, and the two things to prepare for the day it goes wrong. The list below is meant to be read from top to bottom, once, before the first deploy — and again when something changes shape. Half of it is a command; the other half is a decision nobody can make for you. ## Run the command first ```bash trilha audit ``` It refuses to be a formality: it exits non-zero on anything critical, so CI can gate on it. What it checks, in its own order: | Check | Why it is on the list | |---|---| | `TRILHA_SECRET` set and long enough | an unset secret means cookies and CSRF signed with a key that changes on every restart | | trusted proxies declared | without them `ClientIP` is whatever the visitor typed, and the rate limit protects nobody | | allowed hosts declared | a request with someone else's `Host` gets an absolute link — and your cookie — pointing there | | metrics not public, token long enough | `/metrics` is a map of your app: routes, volumes, error rates | | at least one `a.Check` | without one, `ready` says yes while the database is gone | | assets `immutable` | the only cache header that is both safe and worth having, because `c.Asset` hashes the name | | OIDC secret not hardcoded, callback not cleartext | the two ways a login gets stolen | | `trilha_gen.go` fresh, CLI and library on the same version | a generated file that disagrees with `app/` serves the routes of last week | | supported Go, `.gitignore` covering `.env`, `go vet`, `govulncheck` | the ordinary hygiene that is only missed when it fails | Fix everything critical. A warning is a decision: write down why, or fix it. ## What the command cannot see ### Configuration ```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 } ``` Timeouts are the item people skip. A request that never finishes is a connection that never returns, and the failure looks like "the site is slow" until it looks like "the site is down". ### Data - **Backup, and a restore you have actually performed.** A backup nobody restored is a file, not a backup. Time the restore: that number is your worst outage. - **Migrations applied before the new version serves**, not by the instance that just started — with more than one instance, two of them run the same migration at the same time. - **A rollback that works.** A migration that drops a column makes the previous version unable to start. Add the column, deploy, stop using it, drop it in the next release. ### Requests - **Body limit** in `MaxBodyBytes`, raised per route with `c.AllowBody` only where a file arrives. - **Rate limit** on what costs money: login, password reset, anything that sends an e-mail or calls a model. - **`AllowedHosts` and HSTS** together — HSTS is a promise the browser remembers for a year, so turn it on after the certificate works, never before. ### What you will look at when it breaks - **Structured logs going somewhere you can search**, with the request id in them. `c.Log()` already carries it. - **The `/_trilha/health/ready` probe wired to the orchestrator**, and `live` wired to the restart — the other way round restarts a container forever because its database is down. - **An alert on something a person feels**: error rate and p95 latency, not CPU. - **No personal data in the logs.** A log line with an e-mail in it is a copy of your user table in a third-party service. ## The two things to prepare for the bad day 1. **How to roll back.** The previous image, the previous tag, and the certainty that the previous version still talks to the current database. 2. **How to rotate the secret.** `TRILHA_SECRET` gets the new value, `TRILHA_SECRET_PREVIOUS` the old one, for as long as a session lasts. Sessions signed with the old key keep working while they expire; new ones use the new key. Removing the old value ends every session at once, which is exactly what you want if the key leaked. :::note Everything here is one repository's list. If yours has an item this one does not, that item is worth more than all of these — it came from an outage. ::: --- # Migration Source: /trilha/cookbook/migration From plain net/http to Trilha one route at a time, without a rewrite — and what to look at when you move between minor versions. Nobody rewrites a working app. This is the other way: put Trilha in front, move one route, deploy, and repeat until there is nothing left to move. ## From `net/http` Here is the app as it was. A mux with the addresses in a table, a handler that starts by finding out which address it is, a template executed by hand, and the error handling written once per route: ```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 } ``` And the chain everybody writes again — headers, host check, 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) }) } ``` ### The same thing after The address is where the file lives, `app/blog/slug_/page.go`, so nothing declares it twice: ```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) } ``` What disappeared is worth listing, because it is the whole trade: | Written by hand before | Where it went | |---|---| | `mux.HandleFunc("GET /blog/{slug}", …)` | the folder `app/blog/slug_/` | | `http.NotFound` per route | `return trilha.ErrNotFound`, negotiated as HTML or `problem+json` | | `w.Header().Set("Content-Type", …)` | `c.JSON`, `c.HTML`, `c.Text` | | the template, executed and checked | `h`, which is Go and escapes by construction | | the security headers and the `recover` | the runtime, on by default | | the layout repeated in every template | `layout.go` in the folder | ### One route at a time You do not need a big-bang cutover. Trilha's app is an `http.Handler`, and so is your mux, so either can be in front of the other: ```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) } ``` Move the leaves first — a page with no dependencies, an API route that only reads. Deploy after each one. The two systems share the same process, the same pool and the same logger; a route is either in one or the other, never half in both. ### When the app lives inside the old binary `Front` above assumes the two live in the same `package main`. Often they do not: what is being moved is one area of a larger server, and it wants its own folder — `internal/crm/`, with its own `app/`. Declare the package by hand there and `trilha gen` follows it, writing `NewApp` into the same package instead of a `main` nobody asked for: ```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 ``` The binary that already exists mounts it like any other 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))) }) } ``` Three things stop being the app's while it is mounted in there, and all three are one line in `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"} } ``` `Security.Delegated` writes none of the seven headers — the host already wrote them, and two `Content-Security-Policy` on one response is a policy nobody can reason about. `Security.Nonce` is the other half: the app's scripts have to carry the nonce that is in the host's policy, not one it invented for itself. And the CSRF cookie, field and header take names of their own, so the app's hidden `_csrf` and the host's are not the same field on the same page. The fourth is the store. A package variable is shared by every app in the process, and there is more than one in there now: ```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 } ``` There is no hand-written registration file, which is the point: `trilha gen --check` in the CI keeps catching the folder someone added without generating. `trilha dev` and `trilha build` do not apply inside `internal/crm` — the binary is the host — and they say so. See [CLI](/trilha/reference/cli#an-app-inside-a-binary-that-already-exists). Two things do need a decision up front: - **Sessions.** If the old app has its own cookie, keep reading it in a middleware while the new one writes `SetSigned`, and drop the old reader when everything has moved. - **Static files.** `public/` is served by the framework with hashed URLs through `c.Asset`. A path written by hand in old HTML keeps working; it just does not get the long cache. :::tip Start with `trilha new` in an empty directory and copy your handlers into it, rather than adding the framework to the existing tree. Comparing two directories is easier than untangling one. ::: ## Keeping the old shell Migrating a route at a time works until the shell gets in the way: the new page is written in `h`, but the header, the menu and the footer are a `layout.html` that the whole app still shares. Rewriting the shell first is the expensive way round. `tmpl.Wrap` puts the new inside the old: ```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 } ``` The template does not change shape — the slot is the `{{template "conteudo" .}}` it already had: ```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}} ``` Two details make this safe. The app converts nothing to `template.HTML`: what `h` rendered was escaped on the way in, and `tmpl` is the single place that says so. And the shell reaches the CSRF token and the CSP nonce through `trilha.CSRFTokenFrom(r)` and `trilha.NonceFrom(r)`, which answer from the `*http.Request` — the only thing a renderer that does not know the `*Ctx` ever receives, including `templ`, a handler of your own, or a template the app executes itself. Outside a Trilha request both answer `""`. A shell that never reaches the slot — a `{{if}}` that hid it, the wrong slot name — fails the render instead of quietly answering a page with no content. `examples/blog` has a working copy in `app/legado-`. ## Between minor versions The rule the project follows: before 1.0, a minor version may change what a new app looks like, but the upgrade is always written down. In practice, four steps: ```bash go get -u github.com/emersonjoe/trilha@latest go install github.com/emersonjoe/trilha/cmd/trilha@latest trilha gen # the generated file must match the CLI's version trilha audit # among other things, it compares CLI and library make test ``` `trilha audit` is what catches the mismatch nobody notices: a `trilha_gen.go` written by an older CLI serves the routes of an older `app/`. It is a warning, not a crash, which is precisely why it is worth running. The [changelog](https://github.com/emersonjoe/trilha/blob/main/CHANGELOG.md) is the source for what changed; the `## What changes for you` sections of a release are written for this moment. What follows the version bump is ordinary: read the section, run the tests, and if the release added a convention (a new folder name, a new file that gets picked up), `trilha routes` prints what the scanner now sees, which is the fastest way to check it saw what you meant. :::note A public symbol never disappears in a minor version without being deprecated in one first. The versioned surface lives in `api/current.txt`, and a change to it that was not intended fails the framework's own test suite. ::: ### Turning on the agent files in a project that already exists `--agents` is a flag of `trilha new`, so it is of no use to a project that was created before it existed. The command for that case is `trilha agents`, and it does exactly the same thing — nothing has to be recreated: ```bash go get -u github.com/emersonjoe/trilha@latest go install github.com/emersonjoe/trilha/cmd/trilha@latest trilha gen # the generated file must match the CLI's version trilha agents # writes AGENTS.md and CLAUDE.md (--lang pt for Portuguese) trilha check # the single gate: gen, gofmt, vet, test, audit, openapi git add AGENTS.md CLAUDE.md trilha_gen.go ``` Both files are meant to be committed: the agent reads them from the repository, not from your machine. `trilha ctx` needs nothing installed — run it once to see the map your agent will be reading. The step that is easy to miss is `trilha agents` **after every CLI upgrade**. `AGENTS.md` describes the commands of the CLI that wrote it: a copy from 0.36.0 tells the agent to run `make test` and never mentions `trilha check`, which arrived in 0.37.0. An untouched copy is refreshed in silence; one you edited stops the command, and then you choose: - `trilha agents --force` overwrites it and you add your rules back, or - you move your rules to `CLAUDE.md`, which the command never overwrites, and leave `AGENTS.md` as the framework's file — which is what the split is for. In CI, the line the agent files point at is the one that replaces the list of commands: ```yaml - run: trilha check ```