Skip to content
Trilha
Chapters

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#

MethodDescription
Request() *http.Requestthe 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.Contextrequest context (cancellation)
Param(name) stringroute parameter (slug_"slug")
Pattern() stringthe 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) stringfirst value of the query parameter
Form(name) stringform field (parses on demand, with a size limit)
FormErr() errorform parse error: 400 invalid, 413 too large
BindJSON(&v) errordecodes the JSON body; unknown fields are an error (400); 413 above the limit
Cookie(name) (*http.Cookie, error)request cookie
Accepts(offers...) stringthe offer the client prefers (Accept, ranked by q), or ""; an absent or */* header picks the first offer
RequestID() stringreceived X-Request-ID or a generated id
Env() trilha.Envtrilha.Dev or trilha.Prod
Base() stringURL prefix (TRILHA_BASE_PATH), without trailing slash
App() *trilha.Appthe application
Fragment() stringid the client wants to swap (Trilha-Fragment header), or "" on a normal navigation (Interactivity)

Response#

MethodDescription
JSON(code, v) errorwrites JSON with the right Content-Type
Text(code, s) errorwrites plain text
HTML(code, node) errorwrites a node as a whole document, without layouts
Redirect(url) errorreturns 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() []Flashthe 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) errorwrites 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() *StreamServer-Sent Events response: Send(event, data), JSON(event, v), Comment(s), Done(); disables the write timeout (AI and agents)
Writer() http.ResponseWriterdirect access (long downloads, WebSocket)
Written() boolwhether the response has started

HTTP cache#

MethodDescription
ETag(tag) boolwrites ETag (quoting it if needed) and reports whether the request already had it
LastModified(t) boolwrites 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#

MethodDescription
SetTitle(s) / Title() stringpage title, read by layouts
Set(key, v) / Get(key) anyper-request values (middleware → page → layout)

Islands#

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

Long connections and large bodies#

MethodDescription
AllowBody(n int64)body limit for this request, in place of Config.MaxBodyBytes
NoReadDeadline() errordrops this request's read deadline (a slow upload is not an error)
NoWriteDeadline() errordrops 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:

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

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#

MethodDescription
CSRFToken() stringthe 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) stringthe 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) stringthe CSP nonce of the request, same reason and same rule (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.

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.

SymbolRole
FileRules.MaxSize int64limit for this file, apart from Config.MaxBodyBytes; 0 leaves the body limit doing the work
FileRules.Accept []stringmedia types allowed, matched against the detected type: "image/png", "image/*", "*/*"; empty accepts anything
FileRules.Optional boolan absent field returns (nil, nil) instead of an error
Upload.Namesanitised name: no directory, no separator, no control character, at most 100 characters, never empty
Upload.MIME / Upload.Exttype detected in the first 512 bytes, and the extension that matches it
Upload.Size / Upload.Filesize 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() errorcloses 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.