web

package module
v1.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 21, 2026 License: MIT Imports: 56 Imported by: 0

README

web

A web framework for Vuka: routes, views, services and controllers are declared with decorators, parameters bind by name, and web.New() gathers everything into an app.

package main

import (
	"github.com/vuka-lang/ui"
	"github.com/vuka-lang/web"
)

@web.Service
type PetStore struct{ db *DB }            // built from its fields, a lazy singleton

@web.Get("/pets")
@web.Template("views/pets.templ")         // a templ component renders the result
func ListPets(store *PetStore) Result[PetList] { … }

@web.Get("/pets/{id}")
@web.Template("views/pet.html")           // html/template, the result as data
func ShowPet(id int, store *PetStore) Result[Pet] { return store.Find(id) }

@web.Get("/")
func Home(r *http.Request) ui.Node {    // JSX
	return <Layout title="Home"><h1>Hi</h1></Layout>
}

@web.Post("/api/pets")
func CreatePet(in NewPet, store *PetStore) Result[Pet] { … }   // JSON in, JSON out

@web.Controller("/admin/pets")
type PetAdmin struct{ store *PetStore }

@web.Get("/")
func (a *PetAdmin) Index() Result[[]Pet] { return a.store.All() }

func main() {
	app := web.New()       // every route, service and controller declared at init
	app.Provide(OpenDB)    // more constructors, fx-style
	app.Run(":8080")       // graceful shutdown on SIGINT/SIGTERM
}

A complete app is in examples/pets; live components in examples/live.

go get github.com/vuka-lang/web

Declaring

@web.Get(path), Post, Put, Patch, Delete, Handle(method, path) a route; works on functions and controller methods — on an @ui.Island type, a stateless page backed by that component (below)
@web.Template(file [, name]) render the result with a view (below); before or after the route decorator
@web.Use(mw…) HTTP middleware for this route, func(http.Handler) http.Handler
@web.Service a struct becomes a dependency
@web.Controller(prefix) a struct becomes a dependency whose route methods are served under prefix
@web.Live(path, opts…) a stateful component becomes a live page (below) — an @ui.Island type embedding ui.Live, a live page backed by that component; web.Style, web.Script, web.CSS, web.JS give it styles and scripts (below)
@web.Cron(spec) the function runs on a schedule (below)

Paths use {name} for a segment and {name...} for the rest of the path, and match exactly (/pets doesn't match /pets/); a request whose path differs from a route's only by a trailing slash is redirected to it (308, method and query kept), unless something else the app serves answers that path (web.ExactPaths() turns this off). Under a controller, "/" is the prefix itself.

Route decorators are Vuka declarers for a function and type decorators for a type (a vuka.Bundle made with vuka.Dual): they run once at init and record the function with its parameter names, or the type. Other decorators on the same function still wrap it — @audited below runs on every call.

The app

web.New(opts…) takes a snapshot of what has been declared, so several apps (in tests, say) each get their own container and singletons.

Option
web.Provide(ctors…) constructors for the container (also app.Provide)
web.Eager(ctors…) constructors built and started even when nothing needs them (also app.Eager)
web.OnStart(fn), web.OnStop(fn) lifecycle hooks (also app.OnStart/app.OnStop; below)
web.Check(fn) a check Build runs with its own (also app.Check)
web.Options(opts…), web.OptionFunc(f) several options as one; an option written as code (below)
web.Use(mw…) app-wide middleware (also app.Use); runs for every request, 404s included
web.WithRouter(r) another router (below)
web.Only(pkgs…) take only the declarations of these packages (import paths; "main")
web.Dev() development where the process runs in it (VUKA_DEV=1, VUKA_ENV=development) and in tests: internal error messages, Tailwind and Vite for go run . (below); nothing in production
web.DotEnv(files…), web.NoDotEnv(), web.Require(keys…) env files and required variables (below)
web.OnError(f) replace the error page / JSON error body
web.LiveLayout(f), LiveKey(k), LiveOrigins(p…), LiveTokenTTL(d), WithRelay(r) live pages (below)
web.LiveJoinTimeout(d), LiveIdleTimeout(d), LiveMaxConns(n), LiveMaxConnsPerIP(n), LiveMaxConnsPerPage(n), LiveCheckEvery(d) live connections' limits (below)
web.NoTailwind(), web.Tailwind(path) no stylesheet; or the project's Tailwind stylesheet at another path (below)
web.Style(paths…), Script(paths…), CSS(src), JS(src) the app's own stylesheets and scripts, unscoped (below)
web.NoToolbar() no development toolbar (below)
web.NoCrons(), web.CronClock(c) don't schedule the crons; run them by another clock (below)
web.CSRF(trusted…) refuse cross-origin form posts and the like (below)
web.WrapRoutes, ParamFrom, LiveCheck, GuardLiveEvents hooks for libraries guarding routes and live events (below)
web.Frontend(fsys, root, opts…) a single-page app built by Vite (or anything else) served from the binary (below)
web.ExactPaths() no trailing-slash redirect (above)
web.Headers(h), web.NoSecurityHeaders() the security headers every response carries (below)
web.TrustedProxies(cidrs…) the proxies whose X-Forwarded-For/Forwarded/X-Forwarded-Proto count (below)
web.MaxRequestBody(n) the largest request body, 32 MiB by default; a route's own with @web.MaxBody(n) (below)

app.Build() resolves everything — every parameter of every route, every result and view, the whole dependency graph — and returns all problems at once, each naming the route, its source position and the parameter:

web: GET /pets (main.ListPets at pages.vuka:24): component PetsPage: main.PetList has no field for parameter title
web: GET /admin (main.Admin at admin.vuka:9): parameter db *sql.DB: no provider for *sql.DB, needed by main.Admin at admin.vuka:9
web: dependency cycle: *main.A -> *main.B -> *main.A
Options from libraries

A library hands the app one option that does all it needs: web.Options groups options, and web.OptionFunc runs code against the app inside New, before Build, where every App method is open to it.

func Cache(cfg Config) web.Option {
	return web.Options(
		web.Provide(cfg, NewCache),
		web.OptionFunc(func(a *web.App) {
			a.Use(cacheHeaders)
			a.Get("/cache/stats", (*Cache).Stats)
			a.OnStop(func(ctx context.Context) error { return flush(ctx) })
		}),
		web.Check(func(*web.App) error { return cfg.Validate() }),
	)
}

app := web.New(Cache(cfg), web.Dev())

A check's error is one of Build's, reported with the routes' and the container's.

app.Handler() (an http.Handler to mount anywhere; it panics when Build fails), app.Start(ctx) and app.Run(addr) call Build first, so nothing is served from a broken app. Register everything before the first of them. app.Run("") listens on :$PORT, else :8080. web.Resolve[T](app) returns a dependency, for tests and main.

.env files

web.New() first reads env files into the process environment, so every constructor, Start method and option sees their values through os.Getenv or web.Env. By VUKA_ENV, the first file listed winning:

VUKA_ENV files
development, staging, … .env.<env>.local, .env.local, .env.<env>, .env
test (also when unset under go test) .env.test, .env
unset .env.local, .env

The real environment wins over every file: a key is set only when the process doesn't have it yet. .env.local files hold one machine's settings and are skipped in tests, so test runs don't depend on the developer's box. Keys starting with VUKA_ (VUKA_ENV, VUKA_DEV_DIR, VUKA_DEV_LIVE_KEY…) come from the real environment only: an env file setting one is ignored (and logged), so a file next to a deployed binary never switches it into development. web.IsDev() says whether the process runs in development (VUKA_ENV=development, VUKA_DEV=1, or vuka run).

Files are read from the working directory or, when it has none of them, from the nearest parent up to the module root (the directory with go.mod), so go test ./pkg/... finds the project's .env. Without a go.mod in the working directory or above it — a deployed binary — only the working directory is read. Missing files are fine. web.DotEnv("a.env", "b.env") replaces the list (first wins), web.NoDotEnv() reads none, and web.LoadEnv(files…) loads them earlier — for options built from the environment; New reading the same files afterwards changes nothing. With VUKA_ENV=development the app logs which files it read.

Syntax:

# a comment
PORT=8080
export HOST=localhost              # export is allowed; " #" starts a comment
NAME=two words                     # unquoted values are trimmed
RAW='no $expansion, no \n escapes'  # single quotes (and `backticks`) are literal
KEY="-----BEGIN KEY-----
multi-line, with \n \t \" \\ and \$ escapes"
DATABASE_URL=postgres://${HOST}:5432/app
REPLICA_URL="${REPLICA_HOST:-$HOST}"   # :- gives a default for unset or empty
app.name=pets                      # keys: [A-Za-z_][A-Za-z0-9_.]*

$VAR and ${VAR} expand in unquoted and double-quoted values from the real environment, then the keys defined above (in this file or a lower-precedence one); an undefined variable is empty. A malformed line is a Build error with the file and line; nothing of that file is set.

Reading:

port := web.Env("PORT", 8080)                   // int; the default when unset or empty
debug := web.Env[bool]("DEBUG")                 // true/false, 1/0, yes/no, on/off
ttl := web.Env("CACHE_TTL", 5*time.Minute)      // time.Duration
hosts := web.Env[[]string]("ALLOWED_HOSTS")     // comma-separated (any slice of these)
db := web.Env[*url.URL]("DATABASE_URL")         // absolute URLs; also encoding.TextUnmarshaler types
key := web.MustEnv[string]("SECRET_KEY")        // panics when unset or invalid

app := web.New(web.Require("DATABASE_URL", "SECRET_KEY"))

A value that doesn't convert gives the default, and the next app.Build() (or app.Start, for constructors) fails naming the variable. web.Require fails Build listing every missing variable, the files looked for and the keys of .env.example that are unset too:

web: missing environment variables: SECRET_KEY (read .env; looked for .env.local, .env); also unset, listed in .env.example: SMTP_HOST

Errors and logs name variables and files, never values. Keep .env and .env*.local out of git (this repository's .gitignore does) and commit an .env.example with the keys and harmless values instead; in production, set real environment variables.

Parameters

Each parameter binds by its type and name:

parameter from
*http.Request, http.ResponseWriter, context.Context the request (the context is the request's)
a provided type the container
a scalar: string, ints, floats, bool, a encoding.TextUnmarshaler; *T of one (nil when absent), []T (repeated) the path segment of the same name, else the query parameter ?name=; absent → zero value
one struct, or pointer to a struct that isn't provided (with exported fields; else it is a missing dependency) the body: JSON for application/json, else the form (urlencoded, multipart) or query; then path segments into fields of the same name

Struct fields bind by their form tag, else json tag, else name, matched case-insensitively. A malformed value answers 400 with a message (id: "x" is not an integer). Then the struct's validate: tags are checked, nested structs and slices of them included — required, omitempty, min=n/max=n/len=n, oneof=a b, email, the rules of a page backed by a component — and a failure answers 422 with each field's message, keyed by its path as the request named it (json names for a JSON body, else form names):

{"error":"invalid: address.city: required; email: an email address","fields":{"address.city":"required","email":"an email address"},"status":422}

The error is a web.Invalid, so an OnError handler reads the fields with errors.As. A rule that isn't one of these fails Build.

A request body is at most 32 MiB (web.MaxRequestBody(n); negative for none), uploads included — a multipart form spills to temporary files past 10 MiB, and never past the limit. A longer body answers 413, whoever reads it: the binding, or a handler reading r.Body. One route sets its own: @web.MaxBody(512 << 20), or web.Attrs(web.MaxBody(n)) from Go.

A call decorator reaches the request through the handler's context.Context argument: web.Request(c.Context()), web.ResponseWriter(c.Context()).

decorator audited(c) {
	r := web.Request(c.Context())
	c.Next()
	log.Println(r.Method, r.URL.Path, c.Err())
}

@web.Delete("/{id}")
@web.Use(requireToken)
@audited
func (a *PetAdmin) Remove(ctx context.Context, id int) error { … }

Results

A handler returns nothing, error, T, (T, error) or Result[T].

result response
nothing, a nil error 204 — unless the handler wrote the response itself through its http.ResponseWriter
ui.Node (JSX, templ) HTML
T with @web.Template the view, HTML
T without JSON, 200
web.Redirect(url) (as a value or an error) 302 for GET/HEAD, else 303
*web.Error, wrapped or not its status and message
any other error 500; its message only in dev

Errors are an HTML page on HTML routes (a view, a ui.Node or a redirect result) and {"error": …, "status": …} elsewhere. web.NotFound, Unauthorized, Forbidden, Conflict, web.BadRequest(msg), web.Status(code, format, args…); errors.Is(err, web.NotFound) matches by status. web.Redirect is also a ui.Node, so a page handler can return it. Panics in handlers, middleware, custom routers and page rendering are recovered per request. Before the response is committed, they answer 500 through OnError (HTML on page routes, JSON otherwise; middleware/router failures use the request's Accept header). Details and a stack trace stay in the server log; development responses may include the error message. If OnError itself panics before writing, a generic plain-text 500 is used.

A panic after writing or flushing a response aborts that response instead of appending an error body. http.ErrAbortHandler retains its normal abort semantics. Streaming and WebSocket upgrades are not buffered; a panic during live rendering or a live guard closes the affected socket with code 1011 and a generic reason, discarding its session. Other requests keep running. This does not catch panics in application-created goroutines or turn startup configuration errors into successful builds.

A handler that wrote the response and then returns an error can't change it any more: the error is logged.

Forms that fail. A page answering a POST (or PUT, PATCH, DELETE) is the fragment the handler rendered — for htmx, fetch and the like — unless the browser shows it as its document: a form posted without script (Sec-Fetch-Dest: document, or, from a browser that doesn't send it, an Accept: text/html request without HX-Request). That one is a whole page, as GET would serve it — stylesheet, scripts, toolbar — so a handler can re-render the form with its errors and what was typed instead of redirecting. Such a page isn't live (a reconnect would rebuild it with GET): its JS commands run, its stateful components render but don't connect.

@web.Post("/signup")
func SignUp(ctx context.Context, in Signup, users *Users) ui.Node {
	if err := users.Create(ctx, in); err != nil {
		return <SignupPage form={in} problem={err.Error()} />
	}
	return web.Redirect("/welcome")
}

Views

templ. @web.Template("views/pets.templ") uses the file's one exported component; with several, name one: @web.Template("cards.templ", "Card"). The result fills the component's parameters: whole, when it has one parameter the result is assignable to; otherwise the result is a struct (or pointer to one) and each parameter takes the field of its name (exactly, or with the first letter's case changed: title ↔ Title). Layouts are templ's own composition (@Layout(title) { … }).

templ PetsPage(title string, pets []Pet) { @Layout(title) { … } }
type PetList struct { Title string; Pets []Pet }

html/template. Any other file is parsed once at Build and executed with the result; a second argument names a {{define}}d template to execute.

JSX / render functions. Return a ui.Node; no view needed.

Views are embedded at compile time (vuka.File), so the binary carries them, from the handler's package directory or below it. A .templ file beside the handler is part of its package; one in a subfolder is that folder's package, as Go has it — views/pets.templ starts with package views — and Vuka compiles it and imports it for you:

examples/pets/
  pages.vuka        @web.Template("views/pets.templ"), @web.Template("views/pet.html")
  model/pet.go      package model: Pet, shared by handlers and views
  views/layout.templ  package views: Layout
  views/pets.templ    package views: PetsPage, composed with @Layout
  views/pet.html

Only the subfolder's exported components can be views, and its types can't come from the handler's package (package main can't be imported): put shared types in a package of their own, like model here. A JSX page uses a subfolder's component through its import, <views.Layout title="Home">.

Live components

A stateful component (a struct embedding ui.Live) keeps its state on the server and answers its elements' events over a WebSocket; the page changes in place.

@web.Live("/counter")
type Counter struct {
	ui.Live
	Start int // ?start=3
	n     int
}

func (c *Counter) Mount() { c.n = c.Start }
func (c *Counter) Inc()   { c.n++ }

func (c *Counter) Render() ui.Node {
	return <div><output>{c.n}</output><button onClick={c.Inc}>+</button></div>
}

How a page becomes live. Two ways, one mechanism:

  • @web.Live(path) on a stateful component serves it as a GET page. Its exported fields of provided types are injected; the others that hold text bind from the path segment or query parameter of their name (form tag, else the field name, ignoring case). The page is the component inside the layout web.Layout(f, assets…) names, else the app's web.LiveLayout(f, assets…), else a bare document — f is func(page ui.Node) ui.Node. web.Use(mw…) adds middleware. From Go: app.Live("/counter", NewCounter) (or &Counter{Start: 1}, whose fields are the defaults).

  • Any GET route whose page — a ui.Node result, or a templ view — renders a stateful component or an event handler is live by itself:

    @web.Get("/")
    func Home() ui.Node { return <Shell title="Home"><Counter Start={10} /></Shell> }
    

    A Vuka component rendered from a templ view is live too: templ passes the context on. html/template views and JSON are never live.

The first, HTTP render is complete HTML, and before </body> goes <script src="/_vuka/live.js" data-vk-token="…" defer>. The script opens /_vuka/live?t=<token> and sends join; the server runs the same route again — a GET to the URI in the token, with the WebSocket request's headers (cookies, auth), through the app's and the route's middleware, with its dependencies — and the page it renders becomes the connection's live.Session. That second run mounts the instances that keep state (LiveView's two-phase mount). A join the route answers with a redirect sends redirect (the browser follows it); any other non-page answer (a 404, JSON) is an error, and the connection closes.

The route's handler runs once per connection: its result is the page, and the page is rendered again after every event. State belongs in the components.

Side effects in a live page's handler. The route runs twice for one visit — the GET, then the join (and again on each reconnect) — so what it does besides building the page, sending mail or counting a visit, happens twice. web.IsLiveJoin(ctx) is true only on the join's run; skip them there:

@web.Get("/welcome")
func Welcome(ctx context.Context, m *mail.Mailer) (ui.Node, error) {
	if !web.IsLiveJoin(ctx) {
		if err := m.Send(ctx, welcomeMail); err != nil {
			return nil, err
		}
	}
	return <WelcomePage />, nil
}

A navigation over the connection (navigate) is a visit of its own and isn't a join. Web marks its own run, so a client sending X-Vuka-Live can't make it true; events after the join see false too.

Events. onClick={c.Inc} renders data-vk-on-click="c1:0"; the runtime listens on the document for each event type a page names and sends the nearest handler's id, the element's value (a checkbox's "true"/"false"), the key of a keyboard event, and a submitted form's fields. What a handler receives follows its type (Vuka's rules: nothing, the value, ui.Event, url.Values, a struct bound from the form). Two attributes tune it:

attribute
data-vk-debounce="150" send the element's events (not submits) 150ms after the last one
data-vk-key="Enter Escape" send a keyboard event only for these keys

Submits are preventDefaulted; so are clicks on links and on buttons of a form without a submit handler. A form whose submit handler succeeds is reset to what the server rendered; one whose handler returns an error keeps what the user typed. The element of a click or submit carries aria-busy="true" until the reply.

Patches. The reply carries the HTML of each component whose output changed (the page itself is c0). The runtime morphs the element in place: elements are matched by id, else data-vk-id, else position and tag; a focused field keeps its value and caret; another field takes the server's value when it changed. Give list rows an id for stable identity across inserts and removals. data-vk-ignore (with an id) leaves an element as the browser has it. A whole-document c0 patch morphs the <body> and sets the title.

Browser hooks. <html data-vk-state> is connecting, connected, disconnected, idle or closed (style a banner on it). The document receives vk:patched (detail.ids) after each render or patch and vk:error (detail.message) for a handler's error or a failed message; errors are also logged to the console.

Errors. What the browser reads of an error follows HTTP: a *web.Error's message (web.BadRequest("a name, please"), web.Status(409, …)) as it is, and a field that doesn't parse; any other error — a handler's, Mount's or Update's, or a panic — is Internal Server Error, the error itself going to the log (slog, with the page and the handler). In development (web.Dev(), VUKA_ENV=development) the browser gets every error's text.

Broadcasts. web.Broadcast(ctx, topic, msg) — from an event handler, an Update, or any request — delivers msg to every component subscribed to topic (c.Subscribe(topic) in Mount), in every connected page; each takes it in the Update overload of its type and its page gets the patch. app.Broadcast(ctx, topic, msg) does the same from outside a request. Delivery is in-process; web.WithRelay(r) sends broadcasts through a Relay (Publish(ctx, topic, msg) / Subscribe(deliver)) to reach every replica.

@web.Live("/chat/{room}")
type Chat struct {
	ui.Live
	Room  string   // the path segment
	Log   *ChatLog // injected
	lines []string
}

func (c *Chat) Mount()          { c.Subscribe("chat:" + c.Room) }
func (c *Chat) Update(m Said)   { c.lines = append(c.lines, m.Who+": "+m.Text) }
func (c *Chat) Update(j Joined) { c.lines = append(c.lines, j.Who+" joined") }

func (c *Chat) Send(ctx context.Context, f ChatForm) error {
	return web.Broadcast(ctx, "chat:"+c.Room, Said{c.name, f.Text})
}

Connections. One session per connection; its events and broadcasts are handled one at a time, in order, by the connection's goroutine. The server pings every 25 seconds; a closed connection unmounts its components. app.Stop (and Run's shutdown) closes every connection as "going away". The runtime reconnects with jittered exponential backoff (up to ~10s) and joins again — the route runs again and the state starts over; what was not saved elsewhere is lost.

Limits. A socket must join within LiveJoinTimeout (10s); a joined page the browser sends nothing for LiveIdleTimeout (30m; broadcasts don't count) closes with code 4002, dropping its session, and the runtime joins again at once if the page is in view, else when it is shown. At most LiveMaxConns (10,000) sockets are open, LiveMaxConnsPerIP (100) from one client address (web.ClientIP, so through TrustedProxies) and LiveMaxConnsPerPage (4) with one page token; a socket beyond a cap is refused (503, 429) and the runtime retries. A page is checked when it mounts — its join and each navigation run its route through the app's middleware and gates — and its events and broadcasts trust what it mounted with. LiveChecks run on each joined page every LiveCheckEvery (30s): one failing closes the page with 4001, and it reloads; app.CloseLive(match) closes the pages whose context match picks at once. Each limit is an option; 0 lifts it.

Security. The token is the page's URI and an expiry (LiveTokenTTL, 12h by default), HMAC-SHA256-signed with the app's key: a connection can only rebuild a page the app rendered, and it does so with the connecting browser's own cookies, through the route's own middleware. Each app draws a random key; replicas behind one hostname need a shared web.LiveKey(key). A bad or expired token closes the connection with code 4001 and the runtime reloads the page (at most once in 10 seconds) for a fresh one. WebSocket upgrades only from the page's own origin, unless web.LiveOrigins(patterns…) allows more ("*.example.com").

Navigation

A paged list, tabs kept in the URL, a list and its detail pages: moving between them shouldn't reload the document, drop the connection or lose the scroll position of the page you come back to. ui.Link is a link a live page follows over its connection:

<ui.Link href="/books/7">Dune</ui.Link>                 // navigate: another page
<ui.Link href={shelfURL(s.page + 1)} patch>Next</ui.Link> // patch: this page, other parameters

It renders <a href="…" data-vk-link> (="patch"), so it stays a plain link without JavaScript, on a page that isn't live, in a new tab (target, a modifier key, the middle button) and to another origin — the runtime leaves those clicks to the browser.

  • Patch moves to another URL of the same page. The route runs again for the new URL (its middleware too). A Live page whose type has an Update overload taking a web.URLParams struct stays mounted with its state: its props are bound from the URL again, and that Update gets the new params first — bound the same way, from the path segment or query parameter of each field's name. Then the usual patch is sent. The server decides, not the link: a page without such an Update mounts again, Mount seeing the new URL, as a navigate. So Update must repeat every check Mount makes of what the URL names (that the visitor may see order {id}): an error it returns fails the patch, nothing stays mounted, and the browser loads the URL, the route answering as for any request.
  • Navigate moves to another page: its route runs as a page load does (middleware, gates, dependencies, layout), its components mount on the same connection, and the old ones unmount. Under protocol v2 the page arrives as a change to the trees the connection holds: a shared layout isn't sent again.
type ShelfParams struct {
	web.URLParams
	Page int // ?page=
}

func (s *Shelf) Mount()                { s.show(s.Page) }   // the first URL
func (s *Shelf) Update(p ShelfParams)  { s.show(p.Page) }   // each patch

From a handler, c.PushPatch(href) and c.PushNavigate(href) move the page once the reply is applied; in a chain, ui.JS.Patch(href) and ui.JS.Navigate(href) do it from the browser. A URL whose route answers anything but a page — a redirect, an error, JSON, another origin — is loaded as usual, and so is any navigation while the page isn't connected, or one that fails. Only paths and http and https URLs are followed: the runtime refuses to load a javascript:, data: or other URL, wherever it comes from (a command, a fallback, the server), and the server sends no redirect to one (an app's open redirect to javascript: is an error).

In the browser: the address changes with pushState, and back and forward patch or navigate to match (patch within one page's entries); a navigation scrolls to the top or to the URL's #hash, back restores where the page was, and focus moves to the new page's [autofocus] element, else its <main>, else its first component. The page's <title> follows, and its <head> elements the document lacks (stylesheets, scripts, meta tags) are added once; scripts in the new page's body don't run. <html data-vk-navigating> is set while a navigation is in flight, and vk:navigate fires on window when one completes (detail: {url, patch}). Events aren't sent while the page is on its way out, and a new page drops the optimistic pushes still waiting for the old one, without their hooks. A reconnect joins the page the connection moved to.

JS commands

Opening a menu, showing a dialog, moving focus or copying a snippet needs no server: a chain of JS commands as an on… value runs in the browser, at once, and a Push step in it calls a handler when the server should know too.

func (c *Chores) Render() ui.Node {
	return <section>
		<button onClick={ui.JS.RemoveClass(ui.Ref("menu"), "hidden")}>Pick one</button>
		<ul ref="menu" class="menu hidden">
			{for _, ch := range c.chores {
				<li><button value={ch} onClick={ui.JS.AddClass(ui.Ref("menu"), "hidden").Push(c.Pick)}>{ch}</button></li>
			}}
		</ul>
		{for _, ch := range c.chores {
			<p>{ch} <button value={ch} onClick={ui.JS.Confirm("Delete " + ch + "?").Push(c.Delete)}>delete</button></p>
		}}
		<p ref="status">{len(c.chores)} left</p>
	</section>
}

func (c *Chores) Delete(e ui.Event) {
	// …
	c.PushJS(ui.JS.On(ui.Ref("status")).AddClass("flash")) // runs once the reply is applied
}

A chain renders as data-vk-js-<event>="[[name, args], …]" (the live protocol's "JS commands"). The runtime runs it when the event reaches the nearest element with commands or a handler for it: classes and attributes, Show/Hide/Toggle (the style attribute's display, with optional transition classes during/from/to), Transition, Focus, FocusFirst, a focus stack (PushFocus/PopFocus, for dialogs), Dispatch (a CustomEvent), Exec (the commands held in another attribute), Copy/CopyText (the element gets data-copied for 1.5s), ScrollTo, SetValue (fires input and change), and the steps that steer the chain: Key("Escape") (stop for other keys), Confirm(msg) (stop on cancel), Debounce/Throttle (the steps after them, per element and event; a debounced step waiting in a form runs before the form's submit), and If/ElseIf/Else/End over a target's checked state, value, attribute, selector match or existence. Targets: the element itself, ui.Ref(name) (the refed element of the same component instance, not of the instances inside it), ui.Closest(sel), ui.Inner(sel), or a selector. A submit's commands preventDefault it; a push also does what a handler would (links, a form's buttons). An element with both commands and a handler for one event runs the commands first, and a declined Confirm keeps the event from the server. The attribute commands change no event handler attribute (on…) or srcdoc, and set no URL attribute (href, src, action, formaction, xlink:href, poster, data) to a javascript:, data: or vbscript: URL: ui.JS.SetAttr and its kin panic on such a name when the chain is built and sanitize such a value as an href in markup is, and the runtime refuses either in commands from elsewhere (an Exec's attribute).

Pushed commands. c.PushJS(chain) in a handler, an Update or Mount rides the reply ("js":[{"id","ops"}] on render and patch, both protocol versions) and runs after it is applied, on the component's root element.

Reconciliation. What a command changed — a class, an attribute, the style display — stays across the server's patches while the server keeps rendering that attribute as it did when the command ran: an open menu stays open while the list in it updates. When the server renders a different value, the server wins and the browser's change is dropped. A running transition's classes survive a patch until it ends.

Optimistic updates. A like should count at once and a deleted row should go at once, without waiting for the server — and come back if the server refuses. The steps before a Push in a chain are its predictions: they show immediately, and the push's reply settles them.

<button
	class={likedClass(p.liked)}
	onClick={ui.JS.AddClass(ui.This, "liked").SetText(ui.Ref("likes"), fmt.Sprint(p.likes+1)).
		Push(p.Like).OnError(ui.JS.Show(ui.Ref("like-error")))}
>
	Like
</button>
<b ref="likes">{p.likes}</b>
<p ref="like-error" hidden>Couldn't save your like.</p>
  • Success (a reply without an error): the predictions stay, and reconciliation goes on as for any command — wherever the server renders something else (the count with everyone else's likes), the server wins. The OnSuccess chain runs.
  • Failure (the handler returned an error, an error message, no reply within the push's timeout — 10s by default — or the connection dropping before the reply): each prediction is undone, latest first, except where the server has rendered a different value since or a later command changed it. The OnError chain runs, and vk:error fires as for any error. A push made while the page isn't connected isn't sent (the runtime doesn't queue events) and fails at once.
  • Push(h, ui.KeepOnError()) never undoes (the hooks still run); ui.Timeout(d) sets how long a push waits. A reply that comes after its push timed out is applied as usual, without running hooks again.

What counts: classes, attributes, the style display (Show/Hide, a hidden attribute), the text SetText sets — reconciled like an attribute: it stays while the server renders the same text — and a field's value or checked state from SetValue. Each push in a chain settles only the steps since the previous push; a push a branch skips ends its segment too. Hooks run on the element the chain ran for, or the component's root once that element is gone; a server-pushed chain's pushes work the same way. While a push waits, its element has data-vk-pending; after a failure it has data-vk-failed until its next push — hooks for CSS:

[data-vk-pending] { opacity: .6; }
[data-vk-failed] { outline: 1px solid crimson; }

Calling JavaScript. A chart to redraw, confetti on a save, a date picker to open: Call("name", args…) calls a page function with ctx — {el, event, root, target(spec)} — and the arguments, which may be reads of the page at that moment (ui.Ref("q").Value(), .Checked(), .Attr("name")). Register functions with vuka.register; the first line lets a script run before the runtime (its registrations wait in vuka.q):

<script>
  window.vuka ||= { q: [], register(name, fn) { this.q.push([name, fn]); } };
  vuka.register("confetti", (ctx, n) => fire(ctx.el, n));
</script>

Otherwise the name is a dotted path on window ("Picker.refresh", called with Picker as this), never evaluated as code. A returned promise holds the rest of the chain until it settles; false stops the chain as a declined Confirm does. An unknown function, a throw or a rejection stops the chain and fires vk:error on the element (detail: {error, fn}); when it happens among a push's predictions, that push fails without being sent — its predictions are undone and its OnError chain runs. (A chain that waits on a promise doesn't hold back a data-vk-on-… handler on the same element; put the Push in the chain.)

Without a connection. Commands need no session: a page whose only data-vk- attributes are commands gets the runtime script without a token, runs its commands, and never opens a WebSocket — a static page can have menus and dialogs. A Push on such a page does nothing (the console warns once). A page is live when it renders a stateful component, a handler, or a command that pushes to one.

Protocol versions

The runtime joins asking for protocol v2 ({"type":"join","v":2}) and follows whatever the server answers:

  • v2, render trees. Each component instance arrives as a tree: its statics (the markup the JSX fixes) around its dynamics (what Go expressions render), loops as item frames keyed by key, nested instances as references. Statics travel once per connection and are referred to by id; long strings are kept by the connection too. After an event or a broadcast the server sends, for each instance whose output changed, only the dynamics that changed: a new text or attribute, loop steps (keep, skip, insert, move a keyed item, change one in place), a token patch of a long string — or the instance's plain HTML when that is smaller. The runtime applies the changes to the trees it holds, renders the changed instances' HTML from them and morphs the page with it exactly as in v1, so focus, caret, typed values, busy states and data-vk-ignore behave the same. A counter's click is ~70 bytes instead of ~250; a todo list's events ~3–4× less than v1 (the examples' browser tests log both). A reply the runtime can't apply to its trees makes it join again.
  • v1, HTML. Each changed instance's whole HTML (updates), morphed in. A Vuka session that doesn't speak v2 answers a v2 join in v1, and the page works as before.

web.LiveProtocol(1) makes the app answer in v1 whatever the page asks (LiveProtocol(2), the default, uses v2 when the Vuka session speaks it). The format is Vuka's protocol v2; <html data-vk-protocol="2"> says which one a page got.

The wire, beyond Vuka's protocol

Vuka's wire protocol as is, plus:

GET /_vuka/live?t=<token> the WebSocket; the token in the URL, so a bad one is refused before any message
GET /_vuka/live.js?v=<hash> the runtime, immutable for its hash (ETag otherwise)
join runs the route; a second join on the same connection starts over; its v picks the protocol (above)
redirect sent for a join the route redirects; the connection then closes normally (1000); never to a URL that isn't http or https (an error instead)
navigate runs the URL's route (X-Vuka-Live: navigate) and makes its page the session's; the patch reply carries url and token, the new page's join token; a route that doesn't answer with a page gets redirect (its Location, else the URL); patch: true asks to keep the page mounted, which the server does only for an Update taking web.URLParams; a failed Mount, Update or render answers error, nothing staying mounted
close 4001 invalid or expired token, or a LiveCheck failed: reload
close 4002 the page sat idle (LiveIdleTimeout): reconnect once the page is in view
close 1008 no join within LiveJoinTimeout
close 4004 the route isn't a live page: the error message precedes it; no retry
close 1001 the server is stopping: reconnect
request header X-Vuka-Live: join on the route's run for a join (navigate for a navigation's)

A broadcast arriving before join is dropped (the join renders fresh).

Client state

ui.State keeps a value in the browser — a menu's open flag, a tab, a filter — and the page follows it with no round trip. The runtime (live.js) evaluates the bindings and runs the writes, live page or not:

var theme = ui.State("light") // the page's: one value

func Menu() ui.Node {
	open := ui.State(false) // each Menu's own
	return <div>
		<button aria-expanded={open} onClick={open.Toggle()}>Menu</button>
		<ui.Show when={open}>
			<ul class="transition data-closed:opacity-0">…</ul>
		</ui.Show>
	</div>
}

func Tabs() ui.Node {
	tab := ui.State("env")
	f := ui.State(Filters{Sort: "name"})
	return <section>
		<button aria-selected={tab == "env"} onClick={tab.Set("env")}>Env</button>
		<button onClick={tab.Set("logs").Set(f.Kind, "")}>Logs</button>
		<div hidden={tab != "logs" || f.Kind == ""}>…</div>
		<Count kind={f.Kind} /> {/* the same state */}
	</section>
}

A live page's renders leave what the browser set alone; a component sets a state from the server with c.PushJS(tab.Set("logs")). Guide: client state; example: examples/live/state.vuka.

Dependency injection

The container holds constructors — func(deps…) T or func(deps…) (T, error) — and ready values (anything that isn't a function). @web.Service and @web.Controller structs are constructed from their fields (Vuka's vuka.Type.New; tag a field inject:"-" to leave it out). Every value is a lazy singleton per app; dependencies match by exact type (provide a constructor returning the interface to inject an interface). Missing providers, cycles and duplicates are Build errors.

Lifecycle

app.Start (and Run) builds what the routes need, then runs the start sequence; app.Stop runs the stop sequence, its reverse. The sequence holds, in order:

  • ready values given to Provide, and hooks added with OnStart/OnStop, in the order they were added — a ready value was given explicitly, so it starts even when nothing needs it;
  • values built from constructors, in dependency order — only those something needs (a route, a live page, another value), and those provided with web.Eager.

A value implementing Start(context.Context) error is started and one implementing Stop(context.Context) error is stopped. The first failing start ends Start with its error; every stop runs, and their errors are joined.

app := web.New(
	web.Provide(cfg),                 // cfg.Start, if any, runs first
	web.OnStart(migrate),
	web.Eager(NewMailer),             // started though no route takes it
)

Crons

A function runs on a schedule with @web.Cron. It takes a context.Context and dependencies, like a handler, and returns nothing or an error:

@web.Cron("@every 10m")
func forgetUnconfirmed(ctx context.Context, owners *Owners) error { … }

@web.Cron("CRON_TZ=Africa/Dar_es_Salaam 0 7 * * 1-5")
func (r *Reports) Morning(ctx context.Context) error { … }   // a method of a service

app.Cron("digest", "0 8 * * *", sendDigest)                  // from Go
spec
*/15 * * * * five fields: minute, hour, day of the month, month, day of the week; *, a-b, a,b, /step, JAN, MON; Sunday is 0 or 7
@hourly, @daily (@midnight), @weekly, @monthly, @yearly the usual shorthands
@every 30s at each multiple of the duration — :00 and :30 here, on every replica and after a restart
CRON_TZ=Europe/Paris 0 9 * * * the fields read in that zone; else the process's local time

When both day fields are restricted a day matching either runs. Across daylight-saving changes a time the clock skips runs at the jump, and a time it passes twice runs once — unless the hour field is *, when the job keeps its rhythm by the clock. Package web/cron parses specs and computes the next run on its own.

app.Start schedules the crons after building everything they need; Stop stops scheduling, waits for the runs in progress until its context is done, then cancels their context. A run never overlaps the previous one: a tick that finds it still running is skipped and logged. Missed ticks (the machine slept) aren't run late. A panic is recovered and logged with its stack, and errors go to slog with the cron's name. An invalid spec, a parameter that isn't provided or a cron declared twice is a Build error:

web: @web.Cron("61 * * * *") on main.cleanUp at jobs.vuka:12: cron: "61 * * * *": minute: 61 is out of range 0-59

A cron's name is its function's (forgetUnconfirmed; jobs.cleanUp from another package; Reports.Morning). app.RunCron(ctx, name) runs one now and returns its error, for tests and commands; web.NoCrons() serves without scheduling them.

Several replicas. Each process schedules its crons, so every replica runs every tick. Provide a web.CronLock and only the replica that claims a tick runs it:

type CronLock interface {
	Claim(ctx context.Context, name string, tick time.Time) (bool, error)
}

Every replica asks for the same name and tick, so a lock is a unique key — an INSERT of (name, tick) into a table with a unique index (false on a conflict), or Redis's SET cron:name:tick 1 NX EX 3600. Without one the app uses web.NewMemoryCronLock(), which claims each tick once in its process.

Testing. crontest.Run(app, name) runs a cron with its dependencies. crontest.NewClock(t) is a virtual clock (web.CronClock(clock)): nothing fires until the test moves it.

clock := crontest.NewClock(time.Date(2026, 1, 5, 6, 59, 0, 0, time.UTC))
app := web.New(web.CronClock(clock), web.Provide(NewReports))
app.Start(ctx)
clock.WaitTimers(1)          // the scheduler waits for 07:00
clock.Advance(time.Minute)   // the cron runs
crontest.Wait(app)           // and has finished

Mail

web/mail sends email. Provide a *mail.Mailer and inject it:

app := web.New(mail.Provide())   // configured from the environment

@web.Post("/signup")
func SignUp(ctx context.Context, in Signup, m *mail.Mailer) error {
	return m.Send(ctx, mail.Message{
		To:      []string{in.Email},
		Subject: "Welcome to Pets",
		HTML:    <WelcomeEmail name={in.Name} />,   // JSX or templ; the text part is made from it
	})
}
variable
MAIL_DRIVER log (the default: log the message, send nothing) or smtp
MAIL_HOST, MAIL_PORT the SMTP server; the port defaults to 587, 465 with tls, 25 with none
MAIL_USERNAME, MAIL_PASSWORD PLAIN authentication, when set
MAIL_ENCRYPTION starttls (the default; fails when the server can't), tls (from the start), none (local catchers)
MAIL_FROM, MAIL_FROM_NAME the sender when a message names none

mail.Provide(mail.Config{…}) configures it in code instead (also a Transport of your own, a TLS config, timeouts). A Message has From, To, Cc, Bcc, ReplyTo, Subject, Text, HTML (a ui.Node; ui.Safe(s) for a string), Headers and Attachments. Text and HTML go as multipart/alternative, attachments in multipart/mixed, non-ASCII headers and file names encoded. Addresses are parsed, and a line break in any header value is refused before anything is sent (*mail.InvalidError). SMTP is net/smtp with a dial timeout and a deadline; no other dependency.

m.SendLater(ctx, msg) checks and renders the message now and sends it in the background, retrying a temporary failure (a 4xx answer, a failed connection) 3 times, waiting 2s, 4s, 8s; the last failure is logged. The app's Stop waits for the queue within its context. The queue holds at most Config.Queue messages (1000 by default); past it, SendLater returns mail.ErrQueueFull.

Testing. The log driver keeps what it was given: m.Sent() (each with its rendered HTML, Text, Recipients and Raw MIME). mailtest.New() is a quiet one to provide; mailtest.Last(t, m), mailtest.To(t, m, addr) read it, waiting for SendLater first.

A live page's route runs again when its socket joins: mail sent from it goes once with if !web.IsLiveJoin(ctx) { … } around the send (see "Side effects in a live page's handler").

Router

The default router is net/http's ServeMux with Go 1.22 patterns. Any router plugs in through

type Router interface {
	http.Handler
	Handle(method, pattern string, h http.Handler)   // canonical {name} / {name...} patterns
	Param(r *http.Request, name string) string
}

with web.New(web.WithRouter(r)). A chi adapter is a dozen lines (Handle → r.Method(method, pattern, h), Param → chi.URLParam); the tests carry a tiny one.

Tailwind

Write Tailwind classes anywhere — JSX, .templ and .html views, the class names a JS command adds — and they are styled, with no setup:

@web.Get("/")
func Home() ui.Node {
	return <Layout title="Home">
		<button class="rounded bg-emerald-500 px-4 py-2 text-white"
			onClick={ui.JS.AddClass(ui.Ref("note"), "opacity-50")}>Save</button>
		<p ref="note" class="mt-2 text-sm text-gray-500">Unsaved changes</p>
	</Layout>
}

The vuka command builds the stylesheet with the Tailwind CLI (brew install tailwindcss, or npm i -D @tailwindcss/cli) from the module's .vuka, .templ, .html and .go files and those of the dependencies that ship components (github.com/vuka-lang/ui), and every full page the app serves links it: <link rel="stylesheet" href="/_vuka/app.css?v=…"> before </head>, unless the page links it already. Fragments, JSON and live updates don't.

  • vuka run keeps it rebuilt (tailwindcss --watch, into .vuka/tailwind/) and the app serves it from there; open pages swap in each new build without reloading, live state kept.
  • vuka build builds it once, minified, and embeds it in the binary: served as /_vuka/app.css?v=<hash>, cached for good; revalidated by ETag without the hash. vuka gen writes the file that embeds it (vuka_tailwind_gen.go in the main package) for plain go build.

Your own theme, plugins or components go in app.css or styles/app.css at the module root — any file with @import "tailwindcss"; it replaces the built-in one, which is just that line. Elsewhere, name it: web.New(web.Tailwind("assets/site.css")) (a string literal: the vuka command reads it from the source).

Without the vuka command, web.Dev() runs the watcher for go run . (below).

web.NoTailwind() turns it all off, as does VUKA_TAILWIND=off for the vuka command. Without the Tailwind CLI, vuka run says how to install it and serves pages unstyled; vuka build fails.

web.RegisterCSS(name, css) serves another stylesheet at /_vuka/<name>; the file vuka build generates registers app.css with it.

go run

The vuka command is a convenience; a plain go run . gets live CSS and the frontend's dev server from one option:

app := web.New(web.Dev(), web.Frontend(dist, "frontend/dist"))
app.Run("")
VUKA_DEV=1 go run .        # or VUKA_ENV=development

In development Start (Run) runs tailwindcss --watch on the app's stylesheet (as vuka run does, into .vuka/tailwind/) and each frontend's Vite dev server (node_modules/.bin/vite in its project, the Vite plugin copied in), and the app serves through them: pages swap in each new stylesheet, and load their modules from Vite. Their output is logged with tailwind: and vite: prefixes. Stop ends them — SIGTERM to each one's process group, SIGKILL after 2s — and a dev server whose app is gone, however it ended, exits by itself.

What is missing isn't installed for you: the app serves the last build and logs one line saying how to get live CSS and the dev server. Nothing runs twice — not under vuka run, which runs both itself, nor beside a Vite dev server or a VUKA_TAILWIND_CSS watcher already running. In a test binary web.Dev() turns on development (error messages) and starts nothing; in a production binary it does nothing at all, so main keeps it.

Styles and scripts

A component's own stylesheet and script — a keyframe animation, a canvas drawn on mount — are declared beside it, and the vuka command compiles them:

@web.Live("/counter",
	web.Layout(counterPage, web.Style("layout.css")),
	web.Style("counter.css", "parts/*.css"),
	web.Script("counter.ts"),
)
type Counter struct { … }
  • web.Style(paths…), web.Script(paths…), web.CSS(`…`) and web.JS(`…`) are options of @web.Live (the component's), of web.Layout and web.LiveLayout (the layout's own markup) and of web.New (the app's, unscoped). web.Global among the arguments makes one unscoped. Paths are string literals relative to the .vuka file; globs work.
  • Scoped CSS. Every element a scoped component's markup writes carries data-vk-s="<scope>", fixed markup the transpiler adds (a templ layout's too), and its selectors are rewritten to match only those: :global(…) and :root stay unscoped, :deep(…) reaches into child components, :scope is the root, keyframes are the component's own. They compile into the app's Tailwind app.css, so @apply and variants work.
  • Scripts (TypeScript or JavaScript, bundled with esbuild inside the vuka command) are one module per component under /_vuka/c/; every full page of an app with scripts loads a small loader (/_vuka/c/loader.js), which imports a component's module the first time it renders and runs its hooks: mounted(el, ctx) on load, join or arrival, updated after a patch that keeps it, destroyed when it leaves (live navigation too). ctx.push("Inc", payload) calls a Go method of the component as an event — only those its scripts name in ctx.push("…") literals, which the vuka command lists in the manifest and the app allows (live.AllowPush); any other name is refused, ctx.on(name, fn) receives c.PushEvent(name, payload), ctx.ref(name) finds its ref element. ui.JS.Call("bump") reaches the component's own methods before vuka.register's and window's.
  • vuka run keeps them compiled in .vuka/assets (VUKA_ASSETS): a saved stylesheet restyles open pages, a saved script loads on the next mount. vuka build embeds them with a generated vuka_assets_gen.go, which calls web.RegisterAssets(manifest) and web.RegisterScript(name, js).
  • The development toolbar's Assets panel lists the page's scoped components and their files.

Options given from Go (app.Live(…, web.Style(…))) do nothing: the vuka command reads them from .vuka files.

Development toolbar

Why is this page slow, which statements did it run, what did that click do? In development every full page answers on the spot: a handle on its right edge shows the request's time and statement count, and opens a drawer of panels for that request.

  • Request: method, path, the route's pattern, the handler (its name, file and line), the status, the arguments the handler was called with and where each came from (path, query, body, dependency), and the request's and response's headers (cookies and credentials hidden).
  • SQL: each statement in order, with its arguments, rows and time as a bar. A statement run more than once is amber; run three times or more with different arguments it is named as an N+1, one statement per row of an earlier result. A failed or refused statement is red.
  • Timeline: the app's and the route's middleware, each call decorator (@timed), the handler, the render and the statements, as bars against the request's time.
  • Logs: what slog's default logger wrote for the request — with its context (slog.InfoContext(ctx, …)) or from the request's goroutine. The console gets it as before.
  • Live: on a live page, each message its connection carried — the join, each event (Counter.Inc (click)), navigation and broadcast — with its time, statements, reply size and what it ran. A message that failed is red, and so are failures only the browser saw (a push timing out, a dropped connection).
  • Crons: the app's crons, their next and last runs, and a Run now button. Each run is a record of its own, listed under Cron runs: its statements, timeline, logs and error, like a request's.
  • Mail: with web/mail, the messages the request (or cron run) sent or queued — headers, attachments, state — and the HTML in a sandboxed preview.

The list at the top holds the page and each fetch and XHR call it made since it loaded: every response of a development app carries its record's id in X-Vuka-Toolbar. Under vuka run -watch the handle follows the next build: building… 1.2s while it compiles (the last good build still answering), build failed with the compiler's output in the drawer.

It is on under vuka run, web.Dev() or VUKA_ENV=development, and off in a test binary (so tests see the pages the app serves), with web.NoToolbar(), and in any other app: no recording, no routes, no header, no hooks. The toolbar draws itself in a shadow DOM, so the page's styles and its own don't meet, light or dark as the system prefers. The page's HTML is the same but for its script tag (in <head>, beside the stylesheet's link); live updates never carry it. The app keeps its latest 100 requests in memory and serves them under /_vuka/toolbar/.

Statements. orm's ormweb feeds the SQL panel by itself. Another database layer reports its statements with web.NoteStatement(ctx, web.Statement{SQL, Args, DB, Start, Duration, Rows, Err}); app.Toolbar() says whether to bother.

Panels of your own. web.Note(ctx, panel, v) records a value for a panel from request code — a no-op outside development, so it can stay — and web.AddPanel(name, render) draws it:

func (c *Cache) Get(ctx context.Context, key string) ([]byte, bool) {
	v, ok := c.m[key]
	web.Note(ctx, "Cache", Lookup{key, ok})
	return v, ok
}

web.AddPanel("Cache", func(notes []any) ui.Node {
	return <ul>{for _, n := range notes { <li>{n.(Lookup).Key}: {n.(Lookup).Hit}</li> }}</ul>
})

A panel noted without AddPanel lists its values. A panel's markup may use the toolbar's classes: kv and grid tables, dim, errtext, status, and preview for an <iframe> (the Mail panel's).

Authentication

web/auth signs users in and guards the app. The app writes one interface — where users and their passwords live — and every route, live page and live event then needs a sign-in unless marked public:

@web.Service
type Users struct{ db *DB }

func (u *Users) FindLogin(ctx context.Context, login string) (*auth.Identity, string, error) { … } // + the encoded password
func (u *Users) Load(ctx context.Context, id string) (*auth.Identity, error) { … }

@web.Get("/login")
@auth.Public
func Login(r *http.Request) ui.Node { return <auth.LoginPage request={r} /> }

@web.Get("/orders")
@auth.Requires("orders.read")
func Orders(me *auth.Identity, store *Store) Result[[]Order] { return store.For(me.ID) }

@auth.Requires("orders.delete")
func (b *Board) Clear(ctx context.Context) error { … }  // a live event: refused to anyone else, however reached

func main() {
	web.New(auth.Module(auth.Config{Endpoints: "/auth"})).Run("")
}

Cookie sessions for pages and live pages (rotated on sign-in, HttpOnly, SameSite=Lax, Secure under HTTPS), opaque bearer tokens for APIs (POST /auth/token), JWTs issued elsewhere, Argon2id hashes (bcrypt ones upgraded at login; another system's encodings read by Config.Hashers), one error for an unknown login and a wrong password, throttled failures, "remember me" (RememberMe), auth.RevokeUser closing the user's live pages, and authtest for tests. A live event's gate is checked before its handler runs, and again by each gated method it reaches — given the page's context, on any goroutine. A gated call no identity accounts for is refused (LaxGates lets one outside any request run); main and jobs mark their context with auth.System(ctx), and crons are marked by themselves. A failed login takes at least FailedLoginTime (300ms), whatever failed, and throttling counts the account a login finds, not only its spelling. A live page's sign-in is checked when the page mounts (its join, each navigation), its events trusting the identity it mounted with; a sign-in ended through auth (SignOut, Revoke, RevokeUser, a new sign-in, a session found expired) closes its pages at once, and one ended behind auth's back (a store row deleted, another process) is caught within web.LiveCheckEvery (30s), when its identity is also loaded again if LiveRecheck (60s) has passed. Its sessions and tokens are kept in memory unless the app gives it stores. An app's own login handler re-renders its page on a failure (see Forms that fail) with auth.LoginMessage(err); auth.LoginPage keeps the posted login and next. See the auth guide and examples/auth.

CSRF. web.CSRF() (which auth.Module turns on) answers 403 to a POST, PUT, PATCH or DELETE a browser sends from another site's page, read from Sec-Fetch-Site and Origin by net/http's CrossOriginProtection: no token in forms. Requests with Authorization: Bearer pass, as browsers never add one. Live connections check their origin themselves.

Hooks for libraries. What auth builds on, open to any library:

web.WrapRoutes(name, f) f(web.RouteInfo) returns middleware for each route (its method, path, attributes, whether it is a page or a Live page, and a Live page's type); an attribute implementing RouteWrapper() string fails Build when no WrapRoutes of that name is installed
web.Attrs(attrs…) typed attributes for a route or App.Live page added in code
web.ParamFrom(f) parameters of f's result type bind with f(r)
web.Implementation[I](app) the provided value implementing interface I
web.LiveCheck(f) f(ctx) on each joined live page every LiveCheckEvery (30s); an error closes it (4001) and the page reloads
app.CloseLive(match) closes (4001, reload) the joined live pages whose context match(ctx) picks, at once: a library ending a session closes its pages
web.GuardLiveEvents(f) f(ctx, web.LiveHandler) before each live event's handler runs, with the page's context: the handler's name, its component's type, its receiver type and method, its declaration's attributes (Vuka's registry: vuka.FuncOf), whether it is a function literal; an error refuses the event as a handler's error would
web.CurrentLiveEvent() the live event the goroutine handles: its context, its handler's name, Handling() (its handler, or what the handler calls, runs; not the render after it), and Refuse(err) — for a decorator of a handler without a context.Context
web.LiveOrigin() on a goroutine a live connection's goroutine started (one a handler starts), that page's context; nil elsewhere
web.IsLiveJoin(ctx) whether the route runs again for a live page's join
web.TrackRequests(), web.CurrentRequest() whether the calling goroutine serves a request (a live connection's included), and the context of the route it runs
web.CronContext(f) every cron runs with its context passed through f
web.ClientIP(r), web.IsHTTPS(r) the client's address and scheme, through web.TrustedProxies only
web.NoteRequest(ctx, note) a row in the toolbar's Request panel
app.WriteError(w, r, err, html) answer as a handler's error is answered

Frontends

A single-page app — React, Vue, Svelte, anything that builds to static files — is served from the binary beside the app's routes:

//go:embed all:frontend/dist
var dist embed.FS

app := web.New(web.Frontend(dist, "frontend/dist"))
a path naming a file (/assets/index-B3x9kQa1.js, /robots.txt) that file, or 404
any other GET path (/, /pets/7) index.html, so client-side routes survive a reload
the app's routes, live pages, /_vuka/ matched first
.vite/ never served

Files Vite's manifest (.vite/manifest.json) lists as build output, with a content hash in their names, are cached for good (Cache-Control: immutable); everything else, index.html included, is revalidated by its ETag and answered with a 304 when unchanged. Outside development a root without index.html fails Build ("build it first").

web.FrontendAt("/admin") serves it under a prefix (build it with Vite's base: "/admin/"); web.FrontendEnv("APP_NAME", "API_URL") passes those variables' values (the environment's, .env's) to the page: index.html sets them before any module runs, and import.meta.env.APP_NAME reads them. Only the keys named reach the browser.

Development. In development (web.Dev(), VUKA_ENV=development, vuka run) the files are read from disk, and a running Vite dev server takes over: the Vite plugin in vite/vuka.js (vuka() in vite.config) writes <dist>/.vite/vuka-hot.json with the dev server's origin and pid, and the app's pages load their modules from it — the browser stays on the app's address and gets hot module replacement, with no proxy to configure. Other files redirect to the dev server, which has the current public/ ones. A hot file whose process is gone is ignored. With neither a build nor a dev server, pages say how to build. vuka run starts the frontend's Vite (and installs its dependencies first), vuka build runs vite build before embedding; both copy the plugin into frontend/vuka/vite.js. Without the vuka command, web.Dev() starts the dev server for go run . (above). A frontend without a package.json is served as it is. The development toolbar is added to the page; the app's Tailwind stylesheet isn't. An example is in examples/spa.

Islands

A Vue, React or Svelte component placed in a server-rendered page hydrates on its own; the rest of the page ships no JavaScript. Import the component into a .vuka file and its props, read from its TypeScript, are type-checked like any component's; a function prop takes a live view method:

import Week "./web/islands/Week.vue"

<Week days={p.days} onBook={p.Book} client:visible />

A page can be a component: @web.Live on an @ui.Island type embedding ui.Live serves the component as a live page, @web.Get on one without it as a stateless page. Through useLive (from vuka:live, typed from the Go struct) the component reads the exported fields (live.rows.value), sets the ones tagged bind: (v-model="live.q.value", debounced; the server runs Changed after) and calls the exported methods (live.cancel(id)). A live page's sets and calls are live events; a stateless page's are each a signed POST that carries the page's props back — signed for the page and the visitor's sign-in, so no one else can replay them. validate: tags on a bound field, and on a method's struct arguments at any depth, are checked before anything is applied or run: a failure answers 422 with each field's message (fields: {"q": "at most 50 characters"}, level.code), shown as live.q.error or the method's error.fields. ui.Layout in the decorator is the page's layout (over web.Layout and web.LiveLayout): a func(ui.Node) ui.Node, or a func(ctx, ui.Node) ui.Node reading the request; several nest.

@web.Live("/orders")
@ui.Island("./web/components/Orders.vue", ui.Layout(layouts.Page("Orders")))
type Orders struct {
	ui.Live
	Q    string  `json:"q" url:"q" bind:"q"`
	Rows []Order `json:"rows"`
	db   *Store
}

func (o *Orders) Changed(ctx context.Context) error           { … }
func (o *Orders) Cancel(ctx context.Context, id int64) error { … }

The guide: Islands. An example is in examples/islands.

Static files

app.Static("/assets", fsys) serves an fs.FS (an embed.FS sub-tree, os.DirFS(…)). A directory is its index.html, else 404 — never a listing — and dotfiles and dot directories (.env, .git/) are never served, but .well-known/. web.Frontend serves the same way.

Security headers

Every response carries, unless its handler sets them itself:

header default
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
X-Frame-Options DENY
Content-Security-Policy frame-ancestors 'none' ('self' with SAMEORIGIN)
Strict-Transport-Security max-age=31536000, on HTTPS requests only

web.Headers(web.SecurityHeaders{FrameOptions: "SAMEORIGIN", HSTS: -1}) changes them ("-" leaves one out; a negative HSTS too), and web.NoSecurityHeaders() turns them off.

Behind a proxy, web.TrustedProxies("10.0.0.0/8") names it: from those addresses only, web.ClientIP(r) reads the client's address from X-Forwarded-For (else Forwarded) — the nearest one that isn't a trusted proxy — and X-Forwarded-Proto: https counts as HTTPS. Without it, web.ClientIP(r) is the connection's peer and those headers are ignored. Login throttling and the per-address live connection cap count by it.

Plain Go

Everything works from Go without decorators, with the calls they are: @web.Get("/pets/{id}") on ShowPet is web.Routes.Get("/pets/{id}", ShowPet, web.Params("id", "store")) at init, and an App's methods of the same names register on that app alone. Reflection can't see parameter names, so scalar parameters are named with web.Params (the receiver excluded); structs, request values and dependencies bind as usual.

app := web.New()
app.Service(NewStore)
app.Get("/pets/{id}", ShowPet, web.Params("id", "store"), auth.Required())
app.Post("/pets", func(in NewPet, s *Store) (Pet, error) { … })
app.Get("/page", page, web.Template(vuka.File("views/page.html")), web.Use(mw))
app.Live("/board", NewBoard)
app.Live("/applicants", NewApplicants, ui.Island(ui.Layout(layouts.Page("Applicants"))))
app.Controller("/admin/pets", NewPetAdmin)
app.Get("/{id}", (*PetAdmin).Show, web.Params("id"))

A page's constructor builds each page's component, its parameters of provided types injected and the others zero. Options are RouteOptions: web's own, an auth gate, ui.Island, or any value carrying typed attributes. From Go, vuka.File("…") reads from disk relative to the working directory.

Generated code only makes things faster. Live components written as nodes (ui.El, no JSX) patch as whole components: the same page in the browser, larger replies (ui). An island's component works untyped without vuka-live.d.ts: useLive() comes from the island build; only defineProps<T>() of its types needs the file.

Testing

go test ./...                     # the package
vuka test ./...                   # also examples/ and internal/vukatest, written in Vuka

For an app's crons and mail, see crontest and mailtest above.

The browser tests (examples/live, TestBrowserReconnect, TestBrowserJS…) drive a headless Chrome over the DevTools protocol (internal/chrome) and skip when none is found; VUKA_CHROME names the binary. The runtime's JS commands and navigation also run in node over a small DOM (testdata/livejs/js.js, TestLiveJSCommands), skipped without node.

Limits

  • One struct parameter per handler; scalars bind from the path or query only (use *http.Request for headers and cookies).
  • Views live in the handler's package directory or below it (go:embed doesn't reach up); ../shared/x.templ is an error.
  • A vuka.File literal is embedded only where a decorator or attribute takes one, so there is no web.Layout(file) option; compose layouts in templ, or {{define}} them in one html file.
  • Dependencies match by exact type; no named values or groups.
  • Status codes for success are fixed (200/204); write through http.ResponseWriter for anything else.
  • Live patches are whole components' HTML, morphed in the browser (Vuka's phase 1); a live connection's state starts over when it reconnects, and events sent while disconnected are dropped. The live endpoints are at /_vuka/… from the root: an app mounted under a path prefix needs them routed there.
  • Event handlers of a live page get the join's context: web.Request is the join's request, and writing to web.ResponseWriter goes nowhere.

Versions

From v0.50.0, vuka, ui, web and the vuka command are released together under one version; require them at the same version (vuka upgrade moves an app between versions). See Versions and compatibility.

License

MIT

Documentation

Overview

Package web is a web framework for Vuka: routes, views and services are declared with decorators, and web.New gathers them into an app.

@web.Get("/pets/{id}")
@web.Template("pet.html")
func ShowPet(id int, store *PetStore) Result[Pet] { … }

func main() { web.New().Run(":8080") }

Everything a decorator declares is a call of the Routes registry, and an App's methods of the same names register on it alone:

app.Get("/pets/{id}", ShowPet, web.Params("id", "store"))

Index

Constants

View Source
const (

	// ToolbarHeader names the toolbar record of a development app's
	// response; the toolbar lists a page's fetch and XHR calls by it.
	ToolbarHeader = "X-Vuka-Toolbar"
	// DevDirEnv names the session directory vuka run passes the app: the
	// toolbar is on, and with -watch the directory's build.json says how
	// the next build is going.
	DevDirEnv = "VUKA_DEV_DIR"
)
View Source
const (

	// AssetsEnv names the directory vuka run keeps the compiled assets in:
	// manifest.json, the modules (c/), and the island chunks (i/).
	AssetsEnv = "VUKA_ASSETS"
)
View Source
const (

	// CSSEnv names the stylesheet file vuka run keeps up to date.
	CSSEnv = "VUKA_TAILWIND_CSS"
)
View Source
const DefaultPageSize = 20

DefaultPageSize is the page size Paginate and the request binders use when none is given or asked for.

View Source
const Global = ":global"

Global, among an asset's arguments, makes it unscoped: web.Style("print.css", web.Global) styles the whole page, and a global script's hooks run once on the page, el being document.body.

View Source
const MaxPageSize = 200

MaxPageSize caps a size taken from the request, so a client can't ask for an unbounded page.

Variables

View Source
var (
	NotFound     = &Error{Status: http.StatusNotFound, Message: "not found"}
	Unauthorized = &Error{Status: http.StatusUnauthorized, Message: "unauthorized"}
	Forbidden    = &Error{Status: http.StatusForbidden, Message: "forbidden"}
	Conflict     = &Error{Status: http.StatusConflict, Message: "conflict"}
)
View Source
var ErrBodyTooLarge = &Error{Status: http.StatusRequestEntityTooLarge, Message: "request body too large"}

ErrBodyTooLarge answers a request whose body is over its limit.

View Source
var ErrCronRunning = errors.New("the previous run is still running")

ErrCronRunning is RunCron's error when the cron's previous run hasn't finished: runs never overlap.

View Source
var Routes = &Registry{r: registry{views: map[string]declView{}, uses: map[string][]Middleware{}}}

Routes is the registry of the program's declared routes, pages, controllers and services.

Functions

func AcceptsHTML added in v0.11.0

func AcceptsHTML(r *http.Request) bool

AcceptsHTML reports whether r asks for an HTML page (its Accept header names text/html): a browser's navigation or form post, not a script's fetch.

func AddPanel added in v0.9.0

func AddPanel(name string, render func(notes []any) ui.Node)

AddPanel adds a panel to the development toolbar: render draws it for a request from the values the request noted for it (Note), in order. A panel added again under the same name replaces it.

func Broadcast added in v0.2.0

func Broadcast(ctx context.Context, topic string, msg any) error

Broadcast delivers msg to the Update methods of every live component subscribed to topic, in every connected page of the app serving ctx (a request's, or a live event's), and sends each page what changed. A topic of "" reaches every component.

func ClientIP added in v0.15.0

func ClientIP(r *http.Request) string

ClientIP is the address r comes from: its connection's peer, or, when that is one of the app's TrustedProxies, the nearest address in X-Forwarded-For (else Forwarded) that isn't. Throttles and connection caps count by it.

func Controller

func Controller(prefix string) func(*vuka.Type)

Controller makes a struct a service whose route methods are served under prefix.

func Cron added in v0.11.0

func Cron(spec string) func(*vuka.Decl)

Cron runs the function on a schedule (see package cron for the syntax):

@web.Cron("@every 30s")
@web.Cron("0 7 * * *")
@web.Cron("CRON_TZ=Africa/Dar_es_Salaam 0 7 * * 1-5")
func cleanUp(ctx context.Context, store *PetStore) error { … }

Its parameters are a context.Context and provided types; on a method, the receiver is provided too. The cron's name is the function's ("cleanUp"; "jobs.cleanUp" outside package main; "PetStore.Refresh" for a method). An invalid spec fails Build, naming the function and where it is declared.

func CurrentRequest added in v0.15.0

func CurrentRequest() (ctx context.Context, serving bool)

CurrentRequest reports whether the calling goroutine serves an HTTP request of an app with TrackRequests — a live page's connection included — rather than being one the app's own work runs on (a cron's, a job's, main), and the context of the route it is running: a handler's, with its middleware's values; nil outside a route (in app-wide middleware, on a live connection between its routes' runs).

func Delete

func Delete(path string) vuka.Bundle

func Env added in v0.2.0

func Env[T any](key string, def ...T) T

Env returns the environment variable key as a T: a string, bool, integer, float, time.Duration, *url.URL (absolute), a type implementing encoding.TextUnmarshaler, or a slice of those (comma-separated). When key is unset or empty it returns def (or the zero value); when the value doesn't convert, it returns def too and the next Build or Start reports the error.

func Get

func Get(path string) vuka.Bundle

func Handle

func Handle(method, path string) vuka.Bundle

Handle declares a route for method and path: @web.Handle("OPTIONS", "/x"). Path segments in braces bind parameters of the same name: "/pets/{id}"; "{rest...}" matches the rest of the path.

On a type — an @ui.Island struct, with @web.Get — it declares a stateless page backed by that component (see "Pages backed by a component" in the islands guide): its exported fields are the component's props, its exported methods what the component calls. A live one (embedding ui.Live) is declared with @web.Live.

func Implementation added in v0.11.0

func Implementation[T any](a *App) (T, error)

Implementation returns the app's value implementing the interface T: the one provided as T, else the one provided type implementing it. It is an error when none does, or several do. Unlike Resolve it doesn't build the app, so a Check may call it.

func IsDev added in v0.15.0

func IsDev() bool

IsDev reports whether the process runs in development: VUKA_ENV is "development", VUKA_DEV is 1, or vuka run names its session directory (VUKA_DEV_DIR). Both come from the real environment only — an env file can't set them — so a deployed binary is never switched into development by a file next to it. (An app made with the Dev option is in development whatever IsDev says.)

func IsHTTPS added in v0.15.0

func IsHTTPS(r *http.Request) bool

IsHTTPS reports whether r came over HTTPS: its own TLS, or a trusted proxy's X-Forwarded-Proto.

func IsLiveJoin added in v0.13.0

func IsLiveJoin(ctx context.Context) bool

IsLiveJoin reports whether ctx is a live page's route running again for its connection's join: the page was served already (its GET), and the connection rebuilds it — on the first join, and on each reconnect. A handler with side effects — sending mail, counting a visit — skips them then, so they happen once per visit:

if !web.IsLiveJoin(ctx) {
	mailer.Send(ctx, welcome)
}

It is false for the GET itself, for a navigation over the connection (a visit of its own, replacing an HTTP load), and for the page's events and renders after the join, though their context descends from the join's. A client can't make it true: web marks its own run, not a header.

The route runs for every join whether or not the join adopts the GET's session (LiveAdopt) — middleware, gates and the handler itself, which sees IsLiveJoin true either way. Adoption only skips mounting the page's components again, so Mount runs once per view when the join arrives in time; an expired window, a restart or a reconnect mounts them again.

func Live added in v0.2.0

func Live(path string, opts ...LiveOption) func(*vuka.Type)

Live serves a stateful component as a page at path, a GET route:

@web.Live("/rooms/{room}")
type Chat struct {
	vuka.Live
	Room  string     // a prop bound from the path segment, else ?room=
	Store *ChatStore // a prop of a provided type, injected
	log   []string   // state
}

Its exported fields of provided types are injected; the others that hold text bind from the path segment or query parameter of their name. The page is the component inside the app's layout (LiveLayout), or the one Layout names, or else a bare HTML document. Use adds middleware.

On an @ui.Island struct embedding ui.Live it is a live page backed by that component (see islandpage.go); the island's ui.Layout wins over Layout.

func LiveOrigin added in v0.13.0

func LiveOrigin() context.Context

LiveOrigin is the context of the live page whose connection started the calling goroutine — the page's route's, carrying its identity — when a goroutine the connection's own goroutine started calls it: a goroutine an event's handler starts (go b.Clear()), or one a decorator runs a call on. It is nil on any other goroutine: one started further down (by such a goroutine), a pool's worker, a request's, a cron's; and once the page's connection has closed. It reads the goroutine's stack (a few microseconds): a check that has a context.Context asks it instead.

func LivePage added in v0.15.0

func LivePage() context.Context

LivePage is the context of the live page whose connection the calling goroutine serves — the page's route's, carrying its identity — whatever it is doing: handling an event, rendering, delivering a broadcast. It is nil on any other goroutine, and before the connection's first join.

func LoadEnv added in v0.2.0

func LoadEnv(files ...string) error

LoadEnv reads env files into the process environment, as New does: for code that needs their values before New, such as options built from them. No files means the default list (see the README); a key is set only when the environment doesn't have it yet, and the first file listed wins over the later ones. Loading the same files again is a no-op, so New reading them afterwards changes nothing.

func MaxBody added in v0.15.0

func MaxBody(n int64) vuka.Bundle

MaxBody bounds the request body of one route, in bytes (negative for none), in place of the app's MaxRequestBody:

@web.Post("/videos")
@web.MaxBody(512 << 20)
func Upload(r *http.Request) error { … }

From Go: app.Post("/videos", h, web.Attrs(web.MaxBody(512 << 20))).

func MustEnv added in v0.2.0

func MustEnv[T any](key string) T

MustEnv is Env for values that must be there: it panics, naming key, when the variable is unset, empty or doesn't convert.

func Note added in v0.9.0

func Note(ctx context.Context, panel string, v any)

Note records v for the toolbar's panel named panel, drawn by the function AddPanel registered for it, else as a list of the values. It does nothing for a context that no development toolbar watches, so it can stay in production code.

func NoteRequest added in v0.11.0

func NoteRequest(ctx context.Context, n RequestNote)

NoteRequest adds a row to the development toolbar's Request panel for the request (or live message) of ctx: what a library decided about it — who is signed in, which gate let it through. It does nothing for a context that no toolbar watches. Never note a secret.

func NoteStatement added in v0.9.0

func NoteStatement(ctx context.Context, q Statement)

NoteStatement records a statement the request of ctx (or its live page's socket message) ran, for the toolbar's SQL panel. It does nothing for a context that no development toolbar watches.

func PageQuery added in v0.21.0

func PageQuery(r *http.Request) (page, size int)

PageQuery reads page and size straight from a request's query string, for a handler that took the *http.Request rather than a bound struct.

func Paginate added in v0.21.0

func Paginate[Q Pageable[Q, T], T any](ctx context.Context, q Q, page, size int) (ui.Page[T], error)

Paginate reads page (1-based) and size from q — an ORM query (orm.For[T](), a QuerySet) or anything matching Query — with one COUNT and one windowed fetch, and returns the page:

p, err := web.Paginate(ctx, orm.For[Book]().Where(…), page, size)
for _, b := range p.Items { … }

page and size are clamped (size to at least 1, page to the list); it does not run the window fetch when the count is zero.

func PaginateSource added in v0.21.0

func PaginateSource[T any](ctx context.Context, src Source[T], page, size int) (ui.Page[T], error)

PaginateSource is Paginate over a Source: a store that counts and slices itself, with no ORM.

func Patch

func Patch(path string) vuka.Bundle

func Post

func Post(path string) vuka.Bundle

func Put

func Put(path string) vuka.Bundle

func RegisterAssets added in v0.10.0

func RegisterAssets(manifest []byte)

RegisterAssets registers the manifest of the app's compiled styles and scripts. vuka build calls it from a file it generates in the main package.

func RegisterCSS added in v0.6.0

func RegisterCSS(name string, css []byte)

RegisterCSS registers a stylesheet the app serves at /_vuka/<name>; the one named "app.css" is linked from every full page. vuka build calls it from a file it generates in the main package, with the Tailwind CSS built for the binary.

func RegisterIsland added in v0.22.0

func RegisterIsland(name string, js []byte)

RegisterIsland registers an island's bundled chunk (component or adapter), served at /_vuka/i/<name> and cached for good: its name carries its content's hash. vuka build calls it from the file it generates.

func RegisterScript added in v0.10.0

func RegisterScript(name string, js []byte)

RegisterScript registers a component's module, served at /_vuka/c/<name> and cached for good: its name carries its content's hash. vuka build calls it from the file it generates.

func Request

func Request(ctx context.Context) *http.Request

Request is the request being served, from a context.Context a handler received: call decorators reach it through c.Context().

func Resolve

func Resolve[T any](a *App) (T, error)

Resolve returns the app's T, building it and its dependencies.

func ResponseWriter

func ResponseWriter(ctx context.Context) http.ResponseWriter

ResponseWriter is the response being written, from a handler's context.

func Service

func Service(t *vuka.Type)

Service makes a struct a dependency: a lazy singleton built from its fields, each injected by type.

func Slice added in v0.21.0

func Slice[T any](rows []T, page, size int) ui.Page[T]

Slice paginates a slice already in memory, for small lists and tests.

Types

type App

type App struct {
	// contains filtered or unexported fields
}

App is a web application: the routes, services and controllers declared with decorators, and whatever was added in code.

func New

func New(opts ...Option) *App

New makes an app of every route, service and controller declared so far (each app takes its own snapshot, with its own singletons). It first reads the env files into the process environment (see LoadEnv), so constructors and Start methods see their values through os.Getenv.

func (*App) Broadcast added in v0.2.0

func (a *App) Broadcast(ctx context.Context, topic string, msg any) error

Broadcast delivers msg to the components subscribed to topic, as the package's Broadcast does.

func (*App) Build

func (a *App) Build() error

Build resolves the app: every route's parameters, results and view, and the dependency graph. It reports every problem at once, before anything is served; Handler, Start and Run call it.

func (*App) Check added in v0.2.0

func (a *App) Check(fn func(*App) error) *App

Check adds fn to the checks Build runs once the routes are compiled; its error is reported with Build's own.

func (*App) CloseLive added in v0.17.0

func (a *App) CloseLive(match func(ctx context.Context) bool) int

CloseLive closes the app's joined live pages whose context — the one their route ran with, as LiveCheck gets it — match reports true for, with code 4001: each page reloads, its route running again for the browser. It returns how many it closes. A page closes before it handles another message or broadcast; one handling a message closes once that is done. match runs on the caller's goroutine, once per joined page: a library ending a session closes the pages signed in with it this way.

func (*App) Controller added in v1.0.0

func (a *App) Controller(prefix string, ctor any, opts ...RouteOption) *App

Controller makes the struct ctor builds a service whose route methods are served under prefix, as Routes.Controller does for every app.

func (*App) Cron added in v0.11.0

func (a *App) Cron(name, spec string, fn any) *App

Cron runs fn on a schedule, as @web.Cron does; name names it for RunCron, the logs and the toolbar. fn takes a context.Context and provided types.

func (*App) Delete

func (a *App) Delete(path string, fn any, opts ...RouteOption) *App

func (*App) Eager added in v0.2.0

func (a *App) Eager(ctors ...any) *App

Eager adds constructors whose values Start builds (and starts, when they implement Starter) even when nothing needs them. Provided values that aren't functions always start; constructors otherwise only when needed.

func (*App) Get

func (a *App) Get(path string, fn any, opts ...RouteOption) *App

func (*App) Handle

func (a *App) Handle(method, path string, fn any, opts ...RouteOption) *App

Handle registers fn for method and path, as Routes.Handle does for every app.

func (*App) Handler

func (a *App) Handler() http.Handler

Handler is the app as an http.Handler, to serve or mount anywhere. It panics when Build fails.

func (*App) Live added in v0.2.0

func (a *App) Live(path string, page any, opts ...RouteOption) *App

Live serves a stateful component as a page at path, as Routes.Live does for every app: page is its constructor (NewBoard), or a pointer to the component whose fields are the defaults.

func (*App) OnStart added in v0.2.0

func (a *App) OnStart(fn func(context.Context) error) *App

OnStart adds fn to the app's start sequence, after the values supplied and the hooks added before it and before the values Start constructs.

func (*App) OnStop added in v0.2.0

func (a *App) OnStop(fn func(context.Context) error) *App

OnStop adds fn to the app's stop sequence, which runs in the reverse order of the start sequence: fn runs after everything started after it was added has stopped.

func (*App) Page added in v0.30.0

func (a *App) Page(path string, component any, attrs ...any) *App

Page serves a page backed by component's island at path, as @web.Get on its type does: component is a pointer to the @ui.Island struct (only its type counts). attrs are the type's attributes as a decorator would give them (auth gates…).

func (*App) Patch

func (a *App) Patch(path string, fn any, opts ...RouteOption) *App

func (*App) Post

func (a *App) Post(path string, fn any, opts ...RouteOption) *App

func (*App) Provide

func (a *App) Provide(ctors ...any) *App

Provide adds constructors (or ready values) to the app's container.

func (*App) Put

func (a *App) Put(path string, fn any, opts ...RouteOption) *App

func (*App) Run

func (a *App) Run(addr string) error

Run serves the app on addr (":" + $PORT, else ":8080", when empty) until SIGINT or SIGTERM, then shuts down gracefully and stops the services.

func (*App) RunCron added in v0.11.0

func (a *App) RunCron(ctx context.Context, name string) error

RunCron runs the cron name now, in the caller's goroutine with ctx, and returns its error (a panic's too). It doesn't wait for a run in progress: it returns ErrCronRunning. For tests, and commands running a cron by hand; the schedule's lock isn't asked.

func (*App) Service added in v1.0.0

func (a *App) Service(ctor any) *App

Service provides the struct ctor builds as a lazy singleton, as Routes.Service does for every app.

func (*App) Start

func (a *App) Start(ctx context.Context) error

Start builds every dependency the routes and crons need and every Eager one, then runs the start sequence: each supplied or built value implementing Starter and each OnStart hook, in dependency order (supplied values and hooks as added, before the values built from constructors), and last the crons' scheduler, which Stop stops first.

func (*App) Static

func (a *App) Static(prefix string, fsys fs.FS) *App

Static serves the files of fsys under prefix: a directory as its index.html, never as a listing, and no dotfile or dot directory but .well-known.

func (*App) Stop

func (a *App) Stop(ctx context.Context) error

Stop runs the stop sequence: each value implementing Stopper and each OnStop hook, in the reverse order of the start sequence.

func (*App) Toolbar added in v0.9.0

func (a *App) Toolbar() bool

Toolbar reports whether the app shows the development toolbar: for a library feeding it (NoteStatement) only then.

func (*App) Use

func (a *App) Use(mw ...Middleware) *App

Use adds app-wide middleware; the first added runs first.

func (*App) WaitCrons added in v0.11.0

func (a *App) WaitCrons(ctx context.Context) error

WaitCrons waits until no run the schedule (or the toolbar) started is in progress, or ctx is done: for tests moving a virtual clock, before they move it again — a tick finding the previous run still going is skipped.

func (*App) WriteError added in v0.11.0

func (a *App) WriteError(w http.ResponseWriter, r *http.Request, err error, html bool)

WriteError answers r with err as a handler's error is answered: its status and message (a *web.Error), an HTML page when html is set, JSON otherwise, or what OnError writes.

type Asset added in v0.10.0

type Asset struct {
	// contains filtered or unexported fields
}

Asset is a stylesheet or script for a page: an option of @web.Live, web.Layout, web.LiveLayout and web.New. The vuka command reads it from the source and compiles it; the app itself only serves what it compiled.

func CSS added in v0.10.0

func CSS(src ...string) Asset

CSS is a stylesheet written inline, as Style's files are: web.CSS(`output { … }`).

func JS added in v0.10.0

func JS(src ...string) Asset

JS is a hook module written inline, as Script's files are.

func Script added in v0.10.0

func Script(paths ...string) Asset

Script names hook modules (TypeScript or JavaScript) for the component the option belongs to, as Style does stylesheets. They are bundled in order into one module, loaded when the component first renders on a page; each may export default { mounted(el, ctx), updated(el, ctx), destroyed(el, ctx), …methods }. ui.JS.Call("name") reaches the component's own methods before vuka.register's and window's.

func Style added in v0.10.0

func Style(paths ...string) Asset

Style names stylesheets for the component the option belongs to: the live component, or the layout's own markup (app-wide, for web.New). Paths are relative to the .vuka file and may be globs (parts/*.css, styles/**/*.css), expanded in name order; they must be string literals, since the vuka command reads them from the source. Selectors match only the elements the component's own markup renders: :global(…) and :root stay unscoped, :deep(…) reaches into child components, :scope is the component's root element. The stylesheets compile with the app's Tailwind CSS, in the order listed (later ones win), so @apply, theme() and variants work.

func (Asset) Attrs added in v1.0.0

func (Asset) Attrs() []any

type BodyLimit added in v0.15.0

type BodyLimit int64

BodyLimit is the attribute MaxBody puts on a route.

type CronLock added in v0.11.0

type CronLock interface {
	Claim(ctx context.Context, name string, tick time.Time) (bool, error)
}

CronLock decides which process runs a scheduled tick. Each process of an app schedules its crons by itself, so with several replicas every one runs every tick; provide a CronLock (backed by a database or Redis) and only the replica that claims a tick runs it. Every replica asks for the same name and tick — @every ticks too are aligned to a fixed origin — so a lock is a unique key: an INSERT of (name, tick) into a table with a unique index, or Redis's SET name:tick NX with an expiry. Without one, the app uses NewMemoryCronLock, which claims every tick. RunCron doesn't ask.

func NewMemoryCronLock added in v0.11.0

func NewMemoryCronLock() CronLock

NewMemoryCronLock is the lock of a single process: it claims each tick of a cron once.

type Error

type Error struct {
	Status  int
	Message string
	Err     error // the cause, never shown
}

Error is an error with an HTTP status. Returned by a handler (wrapped or not), it answers with that status and message: an HTML page for an HTML route, JSON otherwise.

func BadRequest

func BadRequest(msg string) *Error

BadRequest is a 400 error with msg.

func Status

func Status(status int, format string, args ...any) *Error

Status is an error answering with status and the formatted message.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Is

func (e *Error) Is(target error) bool

Is matches another *Error of the same status: errors.Is(err, web.NotFound).

func (*Error) PublicMessage added in v0.15.0

func (e *Error) PublicMessage() string

PublicMessage is the message the browser reads (live.PublicError): a live page's handler returning an *Error shows its message, as an HTTP route's answer does, where any other error's text stays in the log.

func (*Error) StatusCode added in v0.30.0

func (e *Error) StatusCode() int

StatusCode is the error's status.

func (*Error) Unwrap

func (e *Error) Unwrap() error

type FrontendOption added in v0.11.0

type FrontendOption func(*frontend)

FrontendOption configures a Frontend.

func FrontendAt added in v0.11.0

func FrontendAt(prefix string) FrontendOption

FrontendAt serves the frontend under prefix ("/admin") instead of the root: build it with the same base (Vite's base: "/admin/").

func FrontendEnv added in v0.11.0

func FrontendEnv(keys ...string) FrontendOption

FrontendEnv passes the values of these environment variables (and .env keys, which New has loaded) to the frontend's pages: index.html sets them on a global before any module runs, and the Vite plugin turns import.meta.env.KEY into a read of it. Only the keys named are passed; they reach every visitor, so name only public values. Unset keys are left out.

func FrontendRoot added in v0.22.0

func FrontendRoot(dir string) FrontendOption

FrontendRoot names the frontend project folder — the directory that holds the Vite project (package.json, src, node_modules) whose build output is the dist Frontend serves. The vuka command reads it to root the island build: it is esbuild's resolve base (node_modules for framework islands) and where islands.d.ts is written. It does not change where an @ui.Island("…") path resolves — that is relative to the Go file declaring the island. When unset, the build uses the parent of Frontend's root (its dist's directory).

type Invalid added in v0.30.0

type Invalid map[string]string

Invalid is an error naming fields and what is wrong with each: a component's call answering with it shows each field's message (live.error.fields), a route answers 422 with them.

return web.Invalid{"q": "type at least 2 letters"}

func (Invalid) Error added in v0.30.0

func (e Invalid) Error() string

func (Invalid) FieldErrors added in v0.30.0

func (e Invalid) FieldErrors() map[string]string

func (Invalid) PublicMessage added in v0.30.0

func (e Invalid) PublicMessage() string

func (Invalid) StatusCode added in v0.30.0

func (Invalid) StatusCode() int

func (Invalid) Unwrap added in v0.30.0

func (e Invalid) Unwrap() error

type LiveEvent added in v0.11.0

type LiveEvent struct {
	// contains filtered or unexported fields
}

LiveEvent is the live event a goroutine is handling: CurrentLiveEvent returns it to the event's handler and to its decorators, which, unlike the handler, may have no context.Context to read.

func CurrentLiveEvent added in v0.11.0

func CurrentLiveEvent() *LiveEvent

CurrentLiveEvent is the live event the calling goroutine handles, nil outside one: web's connections handle an event's handler, and every decorator around it, on the connection's goroutine.

func (*LiveEvent) Context added in v0.11.0

func (e *LiveEvent) Context() context.Context

Context is the context the page's route ran with (the join's, or the latest navigation's): its request's values, cookies and middleware's.

func (*LiveEvent) Handler added in v0.11.0

func (e *LiveEvent) Handler() string

Handler names the handler the event targets: "Admin.Delete", or "Admin.Render.func1" for a function literal.

func (*LiveEvent) Handling added in v0.11.0

func (e *LiveEvent) Handling() bool

Handling reports whether the event's handler is running — the handler itself, or anything it calls, a function literal's calls included — rather than the render of what it changed, which follows it on the same goroutine. A gate refusing an event checks calls made while Handling.

func (*LiveEvent) Refuse added in v0.11.0

func (e *LiveEvent) Refuse(err error)

Refuse makes err the event's error, which the page receives as a handler's (vk:error); for a decorator that doesn't let a handler without an error result run.

type LiveHandler added in v0.13.0

type LiveHandler struct {
	Name string // "Board.Clear"; "Board.Render.func1" for a function literal
	// Component is the struct type of the component whose event it is; nil
	// for a handler of a page that is no component.
	Component reflect.Type
	// Recv and Method are a method handler's receiver type (Board for
	// *Board) and name; Recv is nil for a type Vuka registered nothing for.
	Recv   reflect.Type
	Method string
	// Attrs are the handler's typed attributes, written on its declaration
	// (vuka.FuncOf); none for a function literal or a function without.
	// The types' own are vuka.TypeAttrs(Component) and
	// vuka.TypeAttrs(Recv).
	Attrs []any
	// Literal says the handler is a function literal (onClick={func() { … }}):
	// it has no attributes, and the methods it calls are checked by their
	// own decorators when it runs.
	Literal bool
}

LiveHandler describes the handler a live event targets, for a guard (GuardLiveEvents): what Vuka's registry knows of its declaration and of the component it belongs to.

type LiveOption added in v0.2.0

type LiveOption interface {
	RouteOption
	// contains filtered or unexported methods
}

LiveOption configures a page declared with Live; it is a RouteOption too.

func Layout added in v0.2.0

func Layout(layout func(page ui.Node) ui.Node, assets ...Asset) LiveOption

Layout wraps a Live page's component: the page is layout(component). Styles and scripts (Style, Script, CSS, JS) given with it belong to the layout's own markup.

type Middleware

type Middleware = func(http.Handler) http.Handler

Middleware wraps an http.Handler.

type Option

type Option interface {
	// contains filtered or unexported methods
}

Option configures an App.

func CSRF added in v0.11.0

func CSRF(trusted ...string) Option

CSRF refuses cross-origin requests that change state: a POST, PUT, PATCH or DELETE a browser sends from another site's page (a form posted to the app, a fetch) answers 403, so a signed-in user's cookie can't be used by a page they happen to visit. Browsers say where a request comes from (Sec-Fetch-Site, else Origin), and net/http's CrossOriginProtection reads that: no token to put in every form. GET, HEAD and OPTIONS are never refused, so keep them free of side effects.

trusted are other origins allowed to send such requests ("https://admin.example.com"). A request carrying an Authorization: Bearer header is let through: a browser never adds one by itself, so it can't be forged by another site. Requests that say nothing of where they come from (curl, servers) are let through too. Live connections check their origin themselves (LiveOrigins).

func Check added in v0.2.0

func Check(fn func(*App) error) Option

Check adds a check Build runs; see App.Check.

func CronClock added in v0.11.0

func CronClock(c cron.Clock) Option

CronClock runs the app's crons by clock instead of the system's: a virtual one in tests (crontest.Clock).

func CronContext added in v0.15.0

func CronContext(f func(context.Context) context.Context) Option

CronContext has every cron run with its context passed through f: a library marking crons as the app's own work.

func Dev

func Dev() Option

Dev turns on development where the process runs in it: VUKA_DEV=1 or VUKA_ENV=development (`VUKA_DEV=1 go run .`). Responses then show internal errors' messages and the toolbar is on, and Start runs tailwindcss --watch and each frontend's Vite dev server, when installed, and, for an app registering islands, vuka build-islands -watch (the vuka command a tool of the module or on PATH), the app serving through them; Stop ends them. Without the tools the app serves the last build and logs one line saying how to get them. Under vuka run, which runs them itself, it starts nothing. In a test binary it turns on development without starting anything; elsewhere — a production binary — it does nothing, so main can keep it.

func DotEnv added in v0.2.0

func DotEnv(files ...string) Option

DotEnv replaces the env files New reads (see LoadEnv); the first listed wins. Relative paths are read from the working directory, or the nearest parent up to the module root holding one of them.

func Eager added in v0.2.0

func Eager(ctors ...any) Option

Eager provides constructors whose values are built and started by Start even when no route needs them (workers, schedulers).

func ExactPaths added in v0.14.0

func ExactPaths() Option

ExactPaths turns off the trailing-slash redirect: a path differing from a route only by a trailing slash is then a 404 (or whatever else answers it, a frontend's index.html).

func Frontend added in v0.11.0

func Frontend(fsys fs.FS, root string, opts ...FrontendOption) Option

Frontend serves a built single-page app from fsys, the files under root:

//go:embed all:frontend/dist
var dist embed.FS

app := web.New(web.Frontend(dist, "frontend/dist"))

A GET path naming a file (its last segment has an extension) is that file, or 404; any other GET path is index.html, so the app's client-side routes work on a reload and from a link. The app's own routes, live pages and /_vuka/ are matched first. Files Vite's manifest lists as build output whose names carry a content hash are cached for good; everything else, index.html included, is revalidated by its ETag. The .vite directory is never served.

The app's islands are embedded with it: go tool vuka build-islands writes them into root's .vuka directory, and the app serves them from there. A build of islands alone, with no index.html, serves them and answers other paths 404:

//go:generate go tool vuka build-islands

Outside development a root without index.html or islands is a Build error. In development (Dev, VUKA_ENV=development, or vuka run) the files are read from disk when root is found there (beside the source calling Frontend, else in the working directory), a running Vite dev server serves the page's modules, and a frontend not built yet shows a page saying how to build it.

func GuardLiveEvents added in v0.13.0

func GuardLiveEvents(f func(ctx context.Context, h LiveHandler) error) Option

GuardLiveEvents has f decide each live event before its handler runs, given the page's context (the identity it joined with) and the handler: an error refuses the event — the handler doesn't run, and the page receives the error as a handler's (vk:error). A library gating events reads the handler's attributes and its component's type's here, where web dispatches, rather than only inside the handler's call.

func Headers added in v0.15.0

func Headers(h SecurityHeaders) Option

Headers replaces the defaults of the security headers every response carries (see SecurityHeaders): a field left zero keeps its default.

func IslandRoot added in v0.30.0

func IslandRoot(dir string) Option

IslandRoot names the folder island components build in, for an app that serves no frontend of its own (no Frontend): the one holding package.json and node_modules (vue, react, svelte, esbuild…), where islands.d.ts and vuka-live.d.ts are written. The vuka command reads it; it changes nothing at run time. The module root is the default.

func LiveAdopt added in v0.16.0

func LiveAdopt(window time.Duration) Option

LiveAdopt is how long after a live page's GET its join may adopt the session the render mounted, instead of mounting the page's components again: 10 seconds by default, enough for the page to load and connect. 0 (or less) turns adoption off: every join mounts fresh, and renders park nothing. The join still runs the page's route — middleware, gates and the handler, with the connection's own cookies — whether or not it adopts; only the mounting of components it skips.

func LiveCheck added in v0.11.0

func LiveCheck(f func(ctx context.Context) error) Option

LiveCheck has each joined live page checked with f every LiveCheckEvery (30 seconds by default), given the context the page's route ran with: an error closes the connection with code 4001 and the page reloads, its route running again for the browser. A page is checked when it mounts by its route itself — the join and each navigation run it through the app's middleware and gates — and its events and broadcasts trust what it mounted with; LiveCheck is the net for what changes while it sits open without the app hearing of it (a session deleted by another process). A library that ends sessions itself closes their pages at once with CloseLive.

func LiveCheckEvery added in v0.15.0

func LiveCheckEvery(d time.Duration) Option

LiveCheckEvery runs the app's LiveChecks on each joined page this often: 30 seconds by default; 0 never runs them. A page some check fails closes with code 4001 and reloads, so a session ended where the app doesn't hear of it (another process, a store edited by hand) or a revoked permission reaches pages that sit open; an ending the app hears of closes them at once (CloseLive).

func LiveIdentity added in v0.16.0

func LiveIdentity(f func(ctx context.Context) string) Option

LiveIdentity tells live adoption and stateless pages' signed props who a context is signed in as: a stable key for the identity and its sign-in ("" for no one). A join adopts a parked session only when every registered function returns for the join's context what it returned for the render's — the same sign-in, or anonymous both times — and a stateless page's call verifies only for the visitor its props were signed for. A library that signs visitors in registers one, so one visitor's page is never adopted, or its props replayed, by another.

func LiveIdleTimeout added in v0.15.0

func LiveIdleTimeout(d time.Duration) Option

LiveIdleTimeout is how long a joined page may send nothing before its connection closes and its session, with its components' state, is dropped: 30 minutes by default; 0 never closes one. The runtime connects again, rebuilding the page's session, at once when the page is in view and otherwise when it is shown again; broadcasts don't keep a page from being idle.

func LiveJoinTimeout added in v0.15.0

func LiveJoinTimeout(d time.Duration) Option

LiveJoinTimeout is how long a live connection may stay open without joining its page: 10 seconds by default. A socket that sends nothing, or anything but its join, is closed when it runs out.

func LiveKey added in v0.2.0

func LiveKey(key []byte) Option

LiveKey is the key signing live pages' join tokens. Without it each app draws a random one, so a page rendered by one process can't join another: set it when several replicas serve the app.

func LiveLayout added in v0.2.0

func LiveLayout(layout func(page ui.Node) ui.Node, assets ...Asset) Option

LiveLayout is the layout of every Live page without its own. Styles and scripts given with it belong to the layout's own markup, as Layout's do.

func LiveMaxConns added in v0.15.0

func LiveMaxConns(n int) Option

LiveMaxConns caps the app's live connections: 10,000 by default. A socket beyond it is refused with 503 Service Unavailable before it opens, and the runtime tries again later; 0 sets no cap.

func LiveMaxConnsPerIP added in v0.15.0

func LiveMaxConnsPerIP(n int) Option

LiveMaxConnsPerIP caps the live connections from one client address: 100 by default (a building behind one address has many visitors). A socket beyond it is refused with 429 Too Many Requests; 0 sets no cap.

func LiveMaxConnsPerPage added in v0.15.0

func LiveMaxConnsPerPage(n int) Option

LiveMaxConnsPerPage caps the live connections open with the same page token, one rendered page's: 4 by default, room for a reconnect overlapping its old socket. A socket beyond it is refused with 429; 0 sets no cap.

func LiveOrigins added in v0.2.0

func LiveOrigins(patterns ...string) Option

LiveOrigins lets pages of other origins open live connections: host patterns as path.Match takes them ("app.example.com", "*.example.com"). By default only the app's own origin may.

func LiveProtocol added in v0.3.0

func LiveProtocol(v int) Option

LiveProtocol caps the live protocol version the app speaks: 2 (render trees: a page gets each component's statics once, then only the dynamics that changed) by default, or 1 (each changed component's HTML). A page asks for the newest it knows and gets at most this; v2 also needs a Vuka live session that speaks it, else pages get v1.

func LiveTokenTTL added in v0.2.0

func LiveTokenTTL(d time.Duration) Option

LiveTokenTTL is how long a rendered live page may (re)join; 12 hours by default. A page older than that reloads when it reconnects.

func MaxRequestBody added in v0.15.0

func MaxRequestBody(n int64) Option

MaxRequestBody bounds every request's body, in bytes: a body longer gets 413. 32 MiB by default; negative for none. A route sets its own with @web.MaxBody.

func NoCrons added in v0.11.0

func NoCrons() Option

NoCrons keeps Start from scheduling the crons, for a replica that only serves requests; RunCron still runs them.

func NoDotEnv added in v0.2.0

func NoDotEnv() Option

NoDotEnv keeps New from reading env files.

func NoSecurityHeaders added in v0.15.0

func NoSecurityHeaders() Option

NoSecurityHeaders turns the default security headers off: a proxy in front of the app sets them, say.

func NoTailwind added in v0.6.0

func NoTailwind() Option

NoTailwind turns the app's stylesheet off: pages get no link to /_vuka/app.css and the vuka command builds none.

func NoToolbar added in v0.9.0

func NoToolbar() Option

NoToolbar turns the development toolbar off.

func OnError

func OnError(f func(w http.ResponseWriter, r *http.Request, e *Error, html bool)) Option

OnError replaces the error page and JSON error body.

func OnStart added in v0.2.0

func OnStart(fn func(context.Context) error) Option

OnStart adds a hook Start runs; see App.OnStart.

func OnStop added in v0.2.0

func OnStop(fn func(context.Context) error) Option

OnStop adds a hook Stop runs; see App.OnStop.

func Only

func Only(pkgs ...string) Option

Only limits the declared routes, services and controllers an app takes to those of the given packages (import paths; "main" for the main package).

func Options added in v0.2.0

func Options(opts ...Option) Option

Options is one Option applying opts in order; nil options are skipped.

func ParamFrom added in v0.11.0

func ParamFrom[T any](f func(r *http.Request) (T, error)) Option

ParamFrom binds handler parameters of type T with f, before any other rule: f's error answers the request as a handler's would. A library gives handlers its own values this way — the signed-in user, say.

func Provide

func Provide(ctors ...any) Option

Provide adds constructors to the app's container: func(deps…) T or func(deps…) (T, error). A value that isn't a function is provided as is.

func Require added in v0.2.0

func Require(keys ...string) Option

Require fails Build when one of the environment variables is unset or empty, naming each missing one and the env files looked for.

func Tailwind added in v0.6.0

func Tailwind(path string) Option

Tailwind names the project's Tailwind stylesheet, relative to the module root, when it isn't app.css or styles/app.css. The vuka command reads the path from the source, so it must be a string literal.

func TrackRequests added in v0.15.0

func TrackRequests() Option

TrackRequests has the app note the goroutine serving each request, and the context of the route it runs, for CurrentRequest: a library checking work a request's goroutine does without being given the request's context.

func TrustedProxies added in v0.15.0

func TrustedProxies(cidrs ...string) Option

TrustedProxies names the proxies in front of the app, as CIDRs ("10.0.0.0/8", "fd00::/8") or single addresses: from them, and only from them, ClientIP reads X-Forwarded-For (else Forwarded), and an HTTPS request is told by X-Forwarded-Proto. An entry that doesn't parse fails Build.

func WithRelay added in v0.2.0

func WithRelay(r Relay) Option

WithRelay sends the app's broadcasts through r instead of delivering them in-process only.

func WithRouter

func WithRouter(r Router) Option

WithRouter serves the app's routes with r instead of net/http's ServeMux.

func WrapRoutes added in v0.11.0

func WrapRoutes(name string, f func(RouteInfo) Middleware) Option

WrapRoutes has Build call f with each route of the app (decorated or added in code, Live pages included); the middleware f returns, if not nil, wraps the route outside its own (@web.Use) middleware and inside the app's. Functions added first wrap outermost. name is what a RouteAttr names.

type OptionFunc added in v0.2.0

type OptionFunc func(*App)

OptionFunc is an Option written as a function of the App, for libraries defining their own options: it runs inside New, before Build, and may call any of the App's methods (Provide, Use, Handle, OnStart, Check, …).

type PageParams added in v0.21.0

type PageParams struct {
	Page int `form:"page" json:"page"`
	Size int `form:"size" json:"size"`
}

PageParams binds ?page= and ?size= for a plain handler:

func List(p web.PageParams, …) (…) {
	page, size := p.Normalize()
	…
}

Its tags let it bind as a handler's request struct; Normalize applies the defaults and the size cap.

func (PageParams) Normalize added in v0.21.0

func (p PageParams) Normalize() (page, size int)

Normalize returns the 1-based page and the size, defaulting size to DefaultPageSize and capping it at MaxPageSize.

type Pageable added in v0.21.0

type Pageable[Q any, T any] interface {
	Count(ctx context.Context) (int64, error)
	Limit(n int) Q
	Offset(n int) Q
	All(ctx context.Context) ([]T, error)
}

Pageable is the shape of an ORM query a paginator needs: count the matches, narrow to a window with Limit and Offset, and fetch it with All. Q is the query's own type, so Limit and Offset chain. github.com/vuka-lang/orm's QuerySet[T] satisfies Pageable[orm.QuerySet[T], T], so Paginate takes one directly with no import of orm here.

type Pager added in v0.21.0

type Pager[T any] struct {
	// contains filtered or unexported fields
}

Pager is a live view's paging state: the current page and size, and the events a ui.DataTable's controls call (GoTo, Next, Prev, SetSize). A view keeps one, gives it a loader that fetches a page, and renders Result — so it never hand-rolls the page arithmetic or the event handlers:

type Books struct {
	web.Live        // ui.Live
	DB    *DB       // injected
	pager *web.Pager[Book]
}

func (b *Books) Mount(ctx context.Context) error {
	b.pager = web.NewPager(20, func(ctx context.Context, page, size int) (ui.Page[Book], error) {
		return web.Paginate(ctx, orm.For[Book](), page, size)
	})
	return b.pager.Load(ctx)
}

func (b *Books) Render() ui.Node {
	return ui.DataTable[Book]{
		Columns: cols,
		Page:    b.pager.Result(),
		OnPage:  b.pager.GoTo,  // func(context.Context, int) error
	}
}

The loader is called with the page and size the pager holds; after each load the pager adopts the page the result settled on, so Next past the last page is a no-op (and the DataTable disables it anyway).

func NewPager added in v0.21.0

func NewPager[T any](size int, load func(ctx context.Context, page, size int) (ui.Page[T], error)) *Pager[T]

NewPager makes a pager starting on page 1 with the given size (or DefaultPageSize when size < 1); load fetches one page.

func (*Pager[T]) Err added in v0.21.0

func (p *Pager[T]) Err() error

Err is the error of the last Load, if any.

func (*Pager[T]) GoTo added in v0.21.0

func (p *Pager[T]) GoTo(ctx context.Context, n int) error

GoTo moves to page n (1-based) and fetches it.

func (*Pager[T]) Load added in v0.21.0

func (p *Pager[T]) Load(ctx context.Context) error

Load fetches the current page, remembering the result and any error, and clamps the current page to the page the result settled on.

func (*Pager[T]) Next added in v0.21.0

func (p *Pager[T]) Next(ctx context.Context) error

Next moves to the following page and fetches it.

func (*Pager[T]) Page added in v0.21.0

func (p *Pager[T]) Page() int

Page is the current page number.

func (*Pager[T]) Prev added in v0.21.0

func (p *Pager[T]) Prev(ctx context.Context) error

Prev moves to the preceding page and fetches it.

func (*Pager[T]) Reload added in v0.21.0

func (p *Pager[T]) Reload(ctx context.Context) error

Reload returns to page 1 and fetches it, after the data behind the pager changed (a new search or sort).

func (*Pager[T]) Result added in v0.21.0

func (p *Pager[T]) Result() ui.Page[T]

Result is the last loaded page (the zero Page before the first Load).

func (*Pager[T]) SetSize added in v0.21.0

func (p *Pager[T]) SetSize(ctx context.Context, size int) error

SetSize changes the page size, returns to page 1 and fetches it.

func (*Pager[T]) Size added in v0.21.0

func (p *Pager[T]) Size() int

Size is the current page size.

type Redirect

type Redirect string

Redirect, returned by a handler as its result or as its error, redirects to the URL: 302 for GET and HEAD, 303 See Other otherwise. It is also a vuka.Node, so a handler returning a page can redirect instead.

func (Redirect) Error

func (r Redirect) Error() string

func (Redirect) Render

func (r Redirect) Render(context.Context, io.Writer) error

type Registry added in v1.0.0

type Registry struct {
	// contains filtered or unexported fields
}

Registry holds what an app is made of: routes, live pages, controllers and services. Routes is the package's, filled at init by the declarations of every package the program links; New gives each app a copy, and an App's methods of the same names add to its own:

func init() {
	web.Routes.Get("/pets/{id}", ShowPet, web.Params("id", "store"))
	web.Routes.Live("/board", NewBoard)
}

app.Get("/pets/{id}", ShowPet, web.Params("id", "store"))

It is what the decorators are: @web.Get("/pets/{id}") on ShowPet is the call above.

func (*Registry) Controller added in v1.0.0

func (g *Registry) Controller(prefix string, ctor any, opts ...RouteOption) *Registry

Controller makes the struct ctor builds a service whose route methods (their method expressions registered: (*PetAdmin).Index) are served under prefix.

func (*Registry) Delete added in v1.0.0

func (g *Registry) Delete(path string, fn any, opts ...RouteOption) *Registry

func (*Registry) Get added in v1.0.0

func (g *Registry) Get(path string, fn any, opts ...RouteOption) *Registry

func (*Registry) Handle added in v1.0.0

func (g *Registry) Handle(method, path string, fn any, opts ...RouteOption) *Registry

Handle registers fn for method and path: a handler, its parameters bound from the request (scalars by the names Params gives them), or the constructor of an @ui.Island struct (a page backed by its island).

func (*Registry) Live added in v1.0.0

func (g *Registry) Live(path string, page any, opts ...RouteOption) *Registry

Live serves a stateful component as a page at path (see the Live decorator): page is its constructor, NewBoard, which builds each page's component, its parameters of provided types injected and the others zero. With ui.Island among opts it is a page backed by its island.

func (*Registry) Patch added in v1.0.0

func (g *Registry) Patch(path string, fn any, opts ...RouteOption) *Registry

func (*Registry) Post added in v1.0.0

func (g *Registry) Post(path string, fn any, opts ...RouteOption) *Registry

func (*Registry) Put added in v1.0.0

func (g *Registry) Put(path string, fn any, opts ...RouteOption) *Registry

func (*Registry) Service added in v1.0.0

func (g *Registry) Service(ctor any) *Registry

Service provides the struct ctor builds as a dependency, a lazy singleton, as @web.Service does.

type Relay added in v0.2.0

type Relay interface {
	Publish(ctx context.Context, topic string, msg any) error
	Subscribe(deliver func(topic string, msg any))
}

Relay carries broadcasts between the replicas of an app: Publish sends a message to every replica, and each calls the function Subscribe was given with what arrives (its own messages included). Encoding the messages, whose dynamic types pick the Update methods they reach, is the relay's business.

type RequestNote added in v0.11.0

type RequestNote struct {
	Section string // the heading it goes under: "Auth"
	Name    string
	Value   string
	Refused bool // shown as a failure
}

RequestNote is a row of the development toolbar's Request panel.

type Rest added in v1.1.0

type Rest string

Rest is a catch-all path parameter: a field of type Rest binds the rest of the path a "{name...}" segment matches, slashes included.

type FileParams struct{ Path web.Rest } // GET /files/{path...}

type RouteAttr added in v0.11.0

type RouteAttr interface{ RouteWrapper() string }

A RouteAttr is a typed attribute that only a WrapRoutes function gives a meaning to — a permission a route requires, say. Build fails when a route carries one and the app has no WrapRoutes of the name RouteWrapper returns, so a route never goes unguarded because the library guarding it wasn't installed.

type RouteInfo added in v0.11.0

type RouteInfo struct {
	Method, Path string
	Where        string // "main.ShowPet at pages.vuka:24", "live page main.Counter"
	// Attrs are the typed attributes of the route's declaration, after those
	// of its controller's type: the attributes of a @web.Live page's type,
	// or those given with web.Attrs from Go.
	Attrs []any
	Live  bool // a Live page
	// LiveType is a Live page's component type (the struct, not a
	// pointer); nil for other routes.
	LiveType reflect.Type
	// HTML says the route answers with a page (a ui.Node, a view or a Live
	// page): its errors are pages, and a browser may be sent elsewhere.
	HTML bool
}

RouteInfo describes a route for the function WrapRoutes registers.

type RouteOption

type RouteOption interface{ Attrs() []any }

RouteOption configures a registration (App.Get, Registry.Live, …): one of web's (Params, Template, Use, Layout, Wrap, Attrs), another package's (an auth gate, ui.Island) or any value carrying typed attributes, which become the route's, as attributes written on a decorated handler are.

func Attrs added in v0.11.0

func Attrs(attrs ...any) RouteOption

Attrs gives a route registered in code typed attributes, as attributes written on a decorated handler are: they reach WrapRoutes. A vuka.Bundle (a composed decorator, such as one a library's attribute function returns) gives its attributes. It also goes to App.Live.

func GuardOf added in v1.2.0

func GuardOf[T any]() RouteOption

GuardOf runs the guard T before a route's page or handler: a struct, rendering nothing, whose Load(ctx context.Context, deps…) error, its dependencies provided types, checks the request:

type Guard struct{ Params }

func (g *Guard) Load(ctx context.Context, perms *Perms) error {
	if !perms.Can(ctx, "employers.view", g.Employer) {
		return web.Forbidden
	}
	return nil
}

Per request it is a zero T, its path parameters bound as a page's are, then its Load run; guards and layouts load in the order their options are given, before the page mounts or the handler runs, and the first error answers the request as the handler's would: NotFound a 404, Forbidden a 403, a Redirect redirects, as a page or as JSON. A Live page's guards run on its request and on its join. GuardOf implies PageRoute.

func LayoutOf added in v1.1.0

func LayoutOf[T any]() RouteOption

LayoutOf wraps a route's page in the layout T, a stateless struct component (T or *T a ui.Node) with a field Children ui.Node, the page or the next layout. Several nest in the order given, the first outermost, and replace Layout and LiveLayout for the route:

type Layout struct {
	Params            // path parameters, bound as a page's are
	Children ui.Node
	Employer Employer
}

func (l *Layout) Load(ctx context.Context, store *Store) error { … }

Per request each layout is a zero T, its path parameters bound, then its Load run, if it has one: Load(ctx, deps…) returning nothing or an error, its dependencies provided types; its error answers the request. Layouts and guards (GuardOf) load in the order their options are given, before a stateless page's Mount. A Live page's layouts render around it on the request's render; the page's patches render the page. On a handler's route (Post, …) every layout is loaded before the handler runs, whatever it answers, a Load error answering instead; a ui.Node the handler returns for a whole page is wrapped in them. LayoutOf implies PageRoute.

func PageRoute added in v1.1.0

func PageRoute() RouteOption

PageRoute makes a route a page as a pages/ folder declares it, what vuka's folder routes register:

web.Routes.Get("/employers/{employer}", employer.NewIndex, web.PageRoute())

The page's struct is built by its constructor, its parameters of provided types injected and the others zero; its exported fields, promoted ones from embedded structs included, bind as a Live page's do (a provided type injected, text from the path segment, else the query parameter, of its name); a path parameter that doesn't parse or fails its validate: tag answers 404. A page whose pointer has Title() string titles the document: ui.PageTitle reads it in the layouts. A stateless page's Title is read after its Mount; a Live page's once its fields are bound, before Mount, which its session runs as it renders. On Get, the constructor's struct is a stateless page, a ui.Node: its Mount, Mount() or Mount(ctx) returning nothing or an error, runs once its fields are bound, and the page renders. LayoutOf implies PageRoute.

func Params

func Params(names ...string) RouteOption

Params names a handler's parameters in order (a method's receiver excluded), which reflection can't see: scalar parameters bind by name.

func Receiver added in v1.1.0

func Receiver(ctor any) RouteOption

Receiver builds the receiver of a method handler for each request with ctor, a page's constructor, binding the route's path parameters into it as PageRoute does a page's (404 when one doesn't parse):

web.Routes.Post("/employers/{employer}/edit", (*edit.Edit).Post, web.Receiver(edit.NewEdit))

The handler's other parameters bind as any handler's.

func UseOf added in v1.3.0

func UseOf(mw any) RouteOption

UseOf wraps the route in mw, HTTP middleware: a func(next http.Handler) http.Handler, or its constructor, func(deps…) web.Middleware, its dependencies provided types, resolved when the app is built; what vuka's folder routes register for a folder's Middleware. Several wrap in the order given, the first outermost, and they wrap the whole route — outside its Use middleware, its guards and layouts, and its binding, so a path parameter's 404 happens inside them. Not calling next answers the request itself. On a Live page it wraps the GET and the join, never the socket's events.

func View

func View(f vuka.File, name ...string) RouteOption

View renders the route's result with a view, as Template does.

func Wrap

func Wrap(mw ...Middleware) RouteOption

Wrap adds middleware to the route, as Use does.

type Router

type Router interface {
	http.Handler
	Handle(method, pattern string, h http.Handler)
	Param(r *http.Request, name string) string
}

Router matches requests to routes. Patterns use the canonical syntax: "/pets/{id}" binds a segment, "/files/{path...}" the rest of the path, and a pattern matches its path exactly. Param returns a bound value for a request the router dispatched.

type SecurityHeaders added in v0.15.0

type SecurityHeaders struct {
	// ContentTypeOptions: "nosniff" by default, so a browser never runs
	// an upload or a JSON answer as a script or a page.
	ContentTypeOptions string
	// ReferrerPolicy: "strict-origin-when-cross-origin" by default, so a
	// URL's path and query stay on the site.
	ReferrerPolicy string
	// FrameOptions: "DENY" by default, or "SAMEORIGIN": who may show the
	// app's pages in a frame. The CSP's frame-ancestors follows it.
	FrameOptions string
	// CSP is the Content-Security-Policy: "frame-ancestors 'none'" by
	// default ('self' with FrameOptions "SAMEORIGIN"; left out with "-").
	CSP string
	// HSTS is the Strict-Transport-Security max-age sent on HTTPS requests
	// (r.TLS, or X-Forwarded-Proto: https from a trusted proxy); 365 days
	// by default. HSTSSubdomains adds includeSubDomains.
	HSTS           time.Duration
	HSTSSubdomains bool
}

SecurityHeaders are the headers every response of an app carries unless its handler sets them itself. The zero value of a field is its default; "-" (or, for HSTS, a negative duration) leaves the header out.

type ServeMux

type ServeMux struct {
	// contains filtered or unexported fields
}

ServeMux is the default Router: net/http's ServeMux with Go 1.22 patterns.

func NewServeMux

func NewServeMux() *ServeMux

func (*ServeMux) Handle

func (m *ServeMux) Handle(method, pattern string, h http.Handler)

func (*ServeMux) Param

func (m *ServeMux) Param(r *http.Request, name string) string

func (*ServeMux) ServeHTTP

func (m *ServeMux) ServeHTTP(w http.ResponseWriter, r *http.Request)

type Source added in v0.21.0

type Source[T any] interface {
	Count(ctx context.Context) (int, error)
	Slice(ctx context.Context, limit, offset int) ([]T, error)
}

Source is where a paginator reads: the total number of rows a query matches, and one window of them. It has no dependency on any ORM, so web does not import one; an orm.QuerySet[T] is adapted by Paginate (it satisfies Query), and any store can implement Source directly.

type Starter

type Starter interface{ Start(context.Context) error }

Starter is a provided value with work to do when the app starts: Run calls Start in dependency order.

type Statement added in v0.9.0

type Statement struct {
	SQL      string
	Args     []any
	DB       string // the database's name
	Start    time.Time
	Duration time.Duration
	Rows     int64 // rows affected; -1 for a query
	Err      error
	// Refused is a statement the database layer refused before sending it;
	// Err says why.
	Refused bool
}

Statement is a statement for the toolbar's SQL panel: what a database layer passes NoteStatement. orm's ormweb reports the ORM's statements by itself.

type Stopper

type Stopper interface{ Stop(context.Context) error }

Stopper is a provided value with work to do when the app stops: Stop is called in the reverse order of construction.

type Templates added in v1.0.0

type Templates func(*vuka.Decl)

Templates is what Template returns: a declarer giving a route its view, and an option doing so.

func Template

func Template(f vuka.File, name ...string) Templates

Template renders a route's result with a view: a .templ file of the same package (its one exported component, or the one named) or an html/template file (its main template, or the one named). The order of @web.Template and the route's method decorator doesn't matter; from Go it is an option: app.Get("/pets/{id}", ShowPet, web.Template(petView)).

func (Templates) Attrs added in v1.0.0

func (t Templates) Attrs() []any

Attrs makes the view a route's option.

type URLParams added in v0.7.0

type URLParams struct{}

URLParams, embedded in a struct, makes it the parameters a Live page's Update method takes when the page is patched — moved to another URL of its route, the component staying mounted:

type ShelfParams struct {
	web.URLParams
	Page int // ?page=
}

func (s *Shelf) Update(p ShelfParams) { s.load(p.Page) }

Its fields bind from the new URL as the component's props do (path segment, else query parameter, of their name); the component's props are bound again too. Update runs before the patch renders. Mount, not Update, sees the first URL.

Only a page with such an Update stays mounted across a patch, whatever the browser asks: any other mounts again, Mount seeing the new URL. So Update must make every check Mount makes of what the URL names — that the visitor may see order {id}, say. An error it returns fails the patch: nothing of the page stays mounted, and the browser loads the URL, the route answering as for any request.

type Uses

type Uses func(*vuka.Decl)

Uses is what Use returns: a declarer adding middleware to one route, a route's option doing so, and an Option adding it to the whole app.

func Use

func Use(mw ...Middleware) Uses

Use adds HTTP middleware, the first given outermost: @web.Use(mw) on a route, or web.New(web.Use(mw)) for every request of the app.

func (Uses) Attrs added in v1.0.0

func (u Uses) Attrs() []any

Attrs makes the middleware a route's option.

Directories

Path Synopsis
Package auth signs users in to a web app and guards its routes, live pages and live events.
Package auth signs users in to a web app and guards its routes, live pages and live events.
authtest
Package authtest helps test apps using auth: requests and live sessions signed in as anyone, without a login, and Users kept in memory.
Package authtest helps test apps using auth: requests and live sessions signed in as anyone, without a login, and Users kept in memory.
Package cron parses cron schedules and computes their next run: five fields (minute hour day-of-month month day-of-week), the descriptors @yearly, @monthly, @weekly, @daily, @hourly and @every <duration>, and a CRON_TZ= prefix naming the time zone the fields are read in.
Package cron parses cron schedules and computes their next run: five fields (minute hour day-of-month month day-of-week), the descriptors @yearly, @monthly, @weekly, @daily, @hourly and @every <duration>, and a CRON_TZ= prefix naming the time zone the fields are read in.
Package crontest tests an app's crons: Run runs one by name with its dependencies, and Clock is a virtual clock to test schedules with — time stands still until the test moves it.
Package crontest tests an app's crons: Run runs one by name with its dependencies, and Clock is a virtual clock to test schedules with — time stands still until the test moves it.
examples
pets/model
Package model holds the pets app's data types, shared by its handlers and its views.
Package model holds the pets app's data types, shared by its handlers and its views.
internal
chrome
Package chrome drives a headless Chrome over the DevTools protocol, for the tests of live pages: open pages, click, type, press keys and evaluate JavaScript.
Package chrome drives a headless Chrome over the DevTools protocol, for the tests of live pages: open pages, click, type, press keys and evaluate JavaScript.
liveproto
Package liveproto is the seam between the web framework's live transport and the Vuka live session it drives: a Session speaks the protocol version its page joined with (v1: HTML, v2: render trees) and answers each client message with the message to send back.
Package liveproto is the seam between the web framework's live transport and the Vuka live session it drives: a Session speaks the protocol version its page joined with (v1: HTML, v2: render trees) and answers each client message with the message to send back.
livetree
Package livetree is a stand-in for the Vuka live session's protocol v2 until the session speaks it: it runs a v1 session and turns the HTML of each changed instance into render trees and their changes, in the format of Vuka's "Protocol v2: render trees" (docs/features/components.md).
Package livetree is a stand-in for the Vuka live session's protocol v2 until the session speaks it: it runs a v1 session and turns the HTML of each changed instance into render trees and their changes, in the format of Vuka's "Protocol v2: render trees" (docs/features/components.md).
testenv
Package testenv decides what a test does when a tool it needs is missing.
Package testenv decides what a test does when a tool it needs is missing.
Package mail sends email from a web app: a Mailer service apps inject, configured from the environment (or .env), with a log driver for development and tests and an SMTP driver for production.
Package mail sends email from a web app: a Mailer service apps inject, configured from the environment (or .env), with a log driver for development and tests and an SMTP driver for production.
mailtest
Package mailtest tests the mail an app sends: a quiet log-driver Mailer to provide, and helpers reading what it kept.
Package mailtest tests the mail an app sends: a quiet log-driver Mailer to provide, and helpers reading what it kept.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL